fix(inbox): validate persisted model before one-click cloud tasks#2728
fix(inbox): validate persisted model before one-click cloud tasks#2728andrewm4894 wants to merge 1 commit into
Conversation
One-click cloud-task flows (scout chat, Responder setup, home quick actions, Discuss/Create-PR) used the persisted `lastUsedModel` directly without checking it was still offered by the gateway. A stale id — e.g. a model later de-listed for the org — would be sent verbatim and the run failed with a gateway 403. The TaskInput model picker already guards this and silently falls back to the server default, which is why the picker showed Opus while the scout still tried the de-listed model. Centralise the guard in the model resolver: the persisted/pinned model is now passed as a *preference* and only honoured when it appears in the gateway's current options, otherwise the server default is used. Generated-By: PostHog Code Task-Id: f29142f6-4183-48a6-9046-8cba7f8181de
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts:107-115
**Transient resolver failure now hard-blocks runs that previously succeeded**
Before this PR, `resolveDefaultModel` was only called when `!model`, so a transient network error on `getPreviewConfigOptions` only blocked users with no persisted model — users with a `lastUsedModel` were unaffected. Now the resolver is always called when `cloudRegion` is set, but both `desktop-services.ts` and `resolveDefaultModel.ts` swallow all exceptions and return `undefined`. A single `getPreviewConfigOptions` timeout therefore turns `model = undefined`, surfaces "Couldn't start task", and forces a fallback to the task-input screen — even when `preferredModel` was a currently-valid id.
The same regression exists in `useInboxCloudTaskRunner.ts` via the `resolveDefaultModel` wrapper. Consider `model = resolvedModel ?? preferredModel` at both call sites so a gateway outage degrades gracefully (same risk profile as the old code) rather than failing hard.
### Issue 2 of 3
packages/core/src/inbox/reportTaskCreation.ts:39-43
**Shallow option lookup misses nested model groups**
`PreviewConfigChoice` is declared to support recursive nesting (`options?: PreviewConfigChoice[]`), and the comment in the interface says "either flat or wrapped in a labelled group." However the lookup `modelOption.options?.some((o) => o.value === preferredModel)` only inspects the top level. For a grouped structure like `[{ options: [{ value: "claude-opus-4-8" }] }]`, `o.value` is `undefined` for the outer entry and the preferred model would not be found, causing an unnecessary fallback to the server default. If the gateway can return grouped options, this check should recurse or flatten first.
### Issue 3 of 3
packages/core/src/inbox/reportTaskCreation.test.ts:20-55
**Tests could be parameterised**
The five `it(...)` cases for `selectModelFromOptions` all follow the pattern `(options, preferredModel?) → expectedResult` and differ only in input/output values. Per the team's preference, these should use `it.each` (or equivalent) so a new model-availability variant only adds a table row rather than a full `it` block.
Reviews (1): Last reviewed commit: "fix(inbox): validate persisted model bef..." | Re-trigger Greptile |
| let model = preferredModel; | ||
| if (cloudRegion) { | ||
| model = await modelResolver.resolveDefaultModel( | ||
| getCloudUrlFromRegion(cloudRegion), | ||
| adapter, | ||
| preferredModel, | ||
| ); | ||
| } | ||
| if (!model) { |
There was a problem hiding this comment.
Transient resolver failure now hard-blocks runs that previously succeeded
Before this PR, resolveDefaultModel was only called when !model, so a transient network error on getPreviewConfigOptions only blocked users with no persisted model — users with a lastUsedModel were unaffected. Now the resolver is always called when cloudRegion is set, but both desktop-services.ts and resolveDefaultModel.ts swallow all exceptions and return undefined. A single getPreviewConfigOptions timeout therefore turns model = undefined, surfaces "Couldn't start task", and forces a fallback to the task-input screen — even when preferredModel was a currently-valid id.
The same regression exists in useInboxCloudTaskRunner.ts via the resolveDefaultModel wrapper. Consider model = resolvedModel ?? preferredModel at both call sites so a gateway outage degrades gracefully (same risk profile as the old code) rather than failing hard.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts
Line: 107-115
Comment:
**Transient resolver failure now hard-blocks runs that previously succeeded**
Before this PR, `resolveDefaultModel` was only called when `!model`, so a transient network error on `getPreviewConfigOptions` only blocked users with no persisted model — users with a `lastUsedModel` were unaffected. Now the resolver is always called when `cloudRegion` is set, but both `desktop-services.ts` and `resolveDefaultModel.ts` swallow all exceptions and return `undefined`. A single `getPreviewConfigOptions` timeout therefore turns `model = undefined`, surfaces "Couldn't start task", and forces a fallback to the task-input screen — even when `preferredModel` was a currently-valid id.
The same regression exists in `useInboxCloudTaskRunner.ts` via the `resolveDefaultModel` wrapper. Consider `model = resolvedModel ?? preferredModel` at both call sites so a gateway outage degrades gracefully (same risk profile as the old code) rather than failing hard.
How can I resolve this? If you propose a fix, please make it concise.| preferredModel && | ||
| modelOption.options?.some((o) => o.value === preferredModel) | ||
| ) { | ||
| return preferredModel; | ||
| } |
There was a problem hiding this comment.
Shallow option lookup misses nested model groups
PreviewConfigChoice is declared to support recursive nesting (options?: PreviewConfigChoice[]), and the comment in the interface says "either flat or wrapped in a labelled group." However the lookup modelOption.options?.some((o) => o.value === preferredModel) only inspects the top level. For a grouped structure like [{ options: [{ value: "claude-opus-4-8" }] }], o.value is undefined for the outer entry and the preferred model would not be found, causing an unnecessary fallback to the server default. If the gateway can return grouped options, this check should recurse or flatten first.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/inbox/reportTaskCreation.ts
Line: 39-43
Comment:
**Shallow option lookup misses nested model groups**
`PreviewConfigChoice` is declared to support recursive nesting (`options?: PreviewConfigChoice[]`), and the comment in the interface says "either flat or wrapped in a labelled group." However the lookup `modelOption.options?.some((o) => o.value === preferredModel)` only inspects the top level. For a grouped structure like `[{ options: [{ value: "claude-opus-4-8" }] }]`, `o.value` is `undefined` for the outer entry and the preferred model would not be found, causing an unnecessary fallback to the server default. If the gateway can return grouped options, this check should recurse or flatten first.
How can I resolve this? If you propose a fix, please make it concise.| describe("selectModelFromOptions", () => { | ||
| it("returns the server default when no preferred model is given", () => { | ||
| const options = [modelOption("claude-opus-4-8", ["claude-opus-4-8"])]; | ||
| expect(selectModelFromOptions(options)).toBe("claude-opus-4-8"); | ||
| }); | ||
|
|
||
| it("honours the preferred model when the gateway still offers it", () => { | ||
| const options = [ | ||
| modelOption("claude-opus-4-8", ["claude-opus-4-8", "claude-sonnet-4-6"]), | ||
| ]; | ||
| expect(selectModelFromOptions(options, "claude-sonnet-4-6")).toBe( | ||
| "claude-sonnet-4-6", | ||
| ); | ||
| }); | ||
|
|
||
| it("falls back to the server default when the preferred model is no longer offered", () => { | ||
| // The persisted model (e.g. a de-listed fable) is not in the available | ||
| // options, so it must not be returned — otherwise the run 403s. | ||
| const options = [modelOption("claude-opus-4-8", ["claude-opus-4-8"])]; | ||
| expect(selectModelFromOptions(options, "claude-fable-5")).toBe( | ||
| "claude-opus-4-8", | ||
| ); | ||
| }); | ||
|
|
||
| it("ignores an empty preferred model", () => { | ||
| const options = [modelOption("claude-opus-4-8", ["claude-opus-4-8"])]; | ||
| expect(selectModelFromOptions(options, "")).toBe("claude-opus-4-8"); | ||
| expect(selectModelFromOptions(options, null)).toBe("claude-opus-4-8"); | ||
| }); | ||
|
|
||
| it("returns undefined when there is no model option", () => { | ||
| const options: PreviewConfigOption[] = [ | ||
| { id: "mode", category: "mode", type: "select", currentValue: "plan" }, | ||
| ]; | ||
| expect(selectModelFromOptions(options, "claude-opus-4-8")).toBeUndefined(); | ||
| }); |
There was a problem hiding this comment.
The five it(...) cases for selectModelFromOptions all follow the pattern (options, preferredModel?) → expectedResult and differ only in input/output values. Per the team's preference, these should use it.each (or equivalent) so a new model-availability variant only adds a table row rather than a full it block.
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/inbox/reportTaskCreation.test.ts
Line: 20-55
Comment:
**Tests could be parameterised**
The five `it(...)` cases for `selectModelFromOptions` all follow the pattern `(options, preferredModel?) → expectedResult` and differ only in input/output values. Per the team's preference, these should use `it.each` (or equivalent) so a new model-availability variant only adds a table row rather than a full `it` block.
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0a2e5417b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| if ( | ||
| preferredModel && | ||
| modelOption.options?.some((o) => o.value === preferredModel) |
There was a problem hiding this comment.
Flatten grouped model options before validating
When the preview-config model list is returned as grouped select options, this check only inspects the top-level group objects, so a valid preferred or pinned model inside a group is treated as unavailable and the one-click task silently falls back to the server default. The schema and existing TaskInput guard support grouped options via flattening, so this no longer mirrors the picker for grouped model lists; please flatten or recurse before comparing the preferred model.
Useful? React with 👍 / 👎.
| apiHost, | ||
| adapter, | ||
| modelResolver, | ||
| settings.lastUsedModel, |
There was a problem hiding this comment.
Clamp effort after resolving a fallback model
When this call replaces a stale lastUsedModel with a fallback default, the task input below still sends settings.lastUsedReasoningEffort unchanged. If the old model supported a higher tier than the fallback (for example gpt-5.5 with xhigh falling back to a non-5.5 Codex model, or a Claude model with max falling back to Sonnet), the cloud runtime rejects the unsupported effort/model pair in packages/agent/src/server/bin.ts, so the one-click run still fails after the model is repaired; derive or clamp the effort from the resolved model before creating the task.
Useful? React with 👍 / 👎.
| const model = await resolveDefaultModel( | ||
| queryClient, | ||
| apiHost, | ||
| adapter, | ||
| modelResolver, | ||
| settings.lastUsedModel, |
There was a problem hiding this comment.
Update hook mocks for the new resolver dependency
This now calls the model resolver even when lastUsedModel is set, but the existing useDiscussReport hook test mock for useService only returns { createTask }. In that test path modelResolver.resolveDefaultModel is missing, resolveDefaultModel catches the TypeError and returns undefined, so the valid create flow exits as missingModel and createTask is never called; update the test DI mock to return distinct TaskService and ReportModelResolver instances.
Useful? React with 👍 / 👎.
Problem
Creating a scout (and other one-click cloud-task flows) intermittently failed with a gateway
403 — <model> not available, even though the model picker showed Opus selected. The one-click flows read the persistedlastUsedModeldirectly and sent it verbatim, with no check that the gateway still offers it. If that persisted id was a model later de-listed for the org (e.g.claude-fable-5), the run 403'd. The TaskInput picker already guards this — it falls back to the server default when the saved model isn't in the available options — which is exactly why the picker displayed Opus while the scout still tried the de-listed model.Changes
useInboxCloudTaskRunner), Responder setup (ConfigureAgentsSection), home quick actions (useRunWorkstreamAction), and Discuss/Create-PR (SignalReportTaskService).How did you test this?
selectModelFromOptions(preferred kept when offered, falls back when de-listed) and asserted the override is forwarded to the resolver.pnpm --filter @posthog/corevitest on the two affected files: 12 passed.pnpm typecheck(core, ui, apps/code) and Biome lint on changed files: clean.Automatic notifications
Created with PostHog Code from a Slack thread