-
Notifications
You must be signed in to change notification settings - Fork 43
fix(inbox): validate persisted model before one-click cloud tasks #2728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| type PreviewConfigOption, | ||
| selectModelFromOptions, | ||
| } from "./reportTaskCreation"; | ||
|
|
||
| function modelOption( | ||
| currentValue: string, | ||
| available: string[], | ||
| ): PreviewConfigOption { | ||
| return { | ||
| id: "model", | ||
| category: "model", | ||
| type: "select", | ||
| currentValue, | ||
| options: available.map((value) => ({ value })), | ||
| }; | ||
| } | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,47 @@ | ||
| import type { TaskCreationInput } from "@posthog/shared"; | ||
|
|
||
| /** A selectable choice, either flat or wrapped in a labelled group. */ | ||
| export interface PreviewConfigChoice { | ||
| value?: string; | ||
| options?: PreviewConfigChoice[]; | ||
| } | ||
|
|
||
| /** Minimal shape of a preview-config option we scan for the default model. */ | ||
| export interface PreviewConfigOption { | ||
| id?: string; | ||
| category?: string; | ||
| type?: string; | ||
| currentValue?: string | boolean | null; | ||
| options?: PreviewConfigChoice[]; | ||
| } | ||
|
|
||
| /** Pick the default model id out of the agent's preview-config options, if present. */ | ||
| /** | ||
| * Pick the model id out of the agent's preview-config options. | ||
| * | ||
| * When `preferredModel` is supplied (e.g. the user's persisted last-used model) | ||
| * it is honoured only if it is still one of the gateway's available models; | ||
| * otherwise we fall back to the server default (`currentValue`). One-click cloud | ||
| * flows pass their persisted model here so a stale id the gateway no longer | ||
| * offers can't slip through — without the check the run fails with a gateway 403 | ||
| * (e.g. a previously-selected model that was later de-listed for the org). | ||
| */ | ||
| export function selectModelFromOptions( | ||
| options: PreviewConfigOption[], | ||
| preferredModel?: string | null, | ||
| ): string | undefined { | ||
| const modelOption = options.find( | ||
| (o) => o.id === "model" || o.category === "model", | ||
| ); | ||
| if (modelOption?.type !== "select") { | ||
| return undefined; | ||
| } | ||
| if ( | ||
| preferredModel && | ||
| modelOption.options?.some((o) => o.value === preferredModel) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎. |
||
| ) { | ||
| return preferredModel; | ||
| } | ||
|
Comment on lines
+39
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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. |
||
| if ( | ||
| modelOption?.type === "select" && | ||
| typeof modelOption.currentValue === "string" && | ||
| modelOption.currentValue | ||
| ) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -99,13 +99,17 @@ export function useRunWorkstreamAction() { | |
| void (async () => { | ||
| try { | ||
| // The cloud runtime requires a model: action-pinned, then last-used, | ||
| // then the adapter's server default. | ||
| // then the adapter's server default. The preferred candidate is only | ||
| // honoured if the gateway still offers it (the resolver validates it), | ||
| // so a stale persisted/pinned id can't reach the run and 403. | ||
| const adapter = action.adapter ?? lastUsedAdapter; | ||
| let model = action.model ?? lastUsedModel ?? undefined; | ||
| if (!model && cloudRegion) { | ||
| const preferredModel = action.model ?? lastUsedModel ?? undefined; | ||
| let model = preferredModel; | ||
| if (cloudRegion) { | ||
| model = await modelResolver.resolveDefaultModel( | ||
| getCloudUrlFromRegion(cloudRegion), | ||
| adapter, | ||
| preferredModel, | ||
| ); | ||
| } | ||
| if (!model) { | ||
|
Comment on lines
+107
to
115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Before this PR, The same regression exists in Prompt To Fix With AIThis 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. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -135,9 +135,17 @@ export function useInboxCloudTaskRunner({ | |
| const adapter = settings.lastUsedAdapter ?? "claude"; | ||
| const apiHost = getCloudUrlFromRegion(cloudRegion); | ||
|
|
||
| const model = | ||
| settings.lastUsedModel ?? | ||
| (await resolveDefaultModel(queryClient, apiHost, adapter, modelResolver)); | ||
| // Pass the persisted model as a *preference*, not a hard selection: the | ||
| // resolver keeps it only if the gateway still offers it, otherwise it falls | ||
| // back to the server default. A stale id (e.g. one later de-listed for the | ||
| // org) would otherwise be sent here and fail the run with a gateway 403. | ||
| const model = await resolveDefaultModel( | ||
| queryClient, | ||
| apiHost, | ||
| adapter, | ||
| modelResolver, | ||
| settings.lastUsedModel, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this call replaces a stale Useful? React with 👍 / 👎.
Comment on lines
+142
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This now calls the model resolver even when Useful? React with 👍 / 👎. |
||
| ); | ||
|
|
||
| if (!model) { | ||
| sonnerToast.dismiss(toastId); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The five
it(...)cases forselectModelFromOptionsall follow the pattern(options, preferredModel?) → expectedResultand differ only in input/output values. Per the team's preference, these should useit.each(or equivalent) so a new model-availability variant only adds a table row rather than a fullitblock.Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
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!