Skip to content

fix(inbox): validate persisted model before one-click cloud tasks#2728

Open
andrewm4894 wants to merge 1 commit into
mainfrom
posthog-code/fix-stale-model-cloud-tasks
Open

fix(inbox): validate persisted model before one-click cloud tasks#2728
andrewm4894 wants to merge 1 commit into
mainfrom
posthog-code/fix-stale-model-cloud-tasks

Conversation

@andrewm4894

Copy link
Copy Markdown
Member

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 persisted lastUsedModel directly 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

  • The model resolver now takes the persisted/pinned model as a preference: it's honoured only when it appears in the gateway's current options, otherwise the adapter's server default is used. This mirrors the picker's existing guard.
  • Routed all one-click flows through that guard: scout chat (useInboxCloudTaskRunner), Responder setup (ConfigureAgentsSection), home quick actions (useRunWorkstreamAction), and Discuss/Create-PR (SignalReportTaskService).

How did you test this?

  • Added unit tests for the guard in selectModelFromOptions (preferred kept when offered, falls back when de-listed) and asserted the override is forwarded to the resolver.
  • pnpm --filter @posthog/core vitest on the two affected files: 12 passed.
  • pnpm typecheck (core, ui, apps/code) and Biome lint on changed files: clean.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code from a Slack thread

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
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit f0a2e54.

@andrewm4894 andrewm4894 marked this pull request as ready for review June 17, 2026 09:10
@andrewm4894 andrewm4894 self-assigned this Jun 17, 2026
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix 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

Comment on lines +107 to 115
let model = preferredModel;
if (cloudRegion) {
model = await modelResolver.resolveDefaultModel(
getCloudUrlFromRegion(cloudRegion),
adapter,
preferredModel,
);
}
if (!model) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +39 to +43
preferredModel &&
modelOption.options?.some((o) => o.value === preferredModel)
) {
return preferredModel;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +20 to +55
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();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)

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!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +142 to +147
const model = await resolveDefaultModel(
queryClient,
apiHost,
adapter,
modelResolver,
settings.lastUsedModel,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant