diff --git a/packages/core/src/sessions/cloudSessionConfig.test.ts b/packages/core/src/sessions/cloudSessionConfig.test.ts index 8bcf537ba7..5a6499f5e4 100644 --- a/packages/core/src/sessions/cloudSessionConfig.test.ts +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -1,6 +1,7 @@ import type { StoredLogEntry } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { + addMissingCloudRuntimeConfigOptions, buildCloudDefaultConfigOptions, extractLatestConfigOptionsFromEntries, } from "./cloudSessionConfig"; @@ -89,3 +90,50 @@ describe("buildCloudDefaultConfigOptions", () => { expect(options.at(-1)?.id).toBe("model"); }); }); + +describe("addMissingCloudRuntimeConfigOptions", () => { + it("seeds selected model and reasoning values for codex cloud sessions", () => { + const options = addMissingCloudRuntimeConfigOptions( + buildCloudDefaultConfigOptions("auto", "codex"), + "codex", + "gpt-5.6-sol", + "max", + ); + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "model", + category: "model", + currentValue: "gpt-5.6-sol", + }), + expect.objectContaining({ + id: "reasoning_effort", + category: "thought_level", + currentValue: "max", + }), + ]), + ); + }); + + it("keeps preview-provided runtime options unchanged", () => { + const existing = buildCloudDefaultConfigOptions("plan", "claude", [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-opus-4-7", + options: [{ value: "claude-opus-4-7", name: "Opus 4.7" }], + category: "model", + }, + ]); + + expect( + addMissingCloudRuntimeConfigOptions( + existing, + "claude", + "claude-sonnet-4-6", + ), + ).toBe(existing); + }); +}); diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index cc6c92811f..ecb989b446 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -76,3 +76,39 @@ export function buildCloudDefaultConfigOptions( ...extra, ]; } + +export function addMissingCloudRuntimeConfigOptions( + configOptions: SessionConfigOption[], + adapter: Adapter, + initialModel?: string, + initialReasoningEffort?: string, +): SessionConfigOption[] { + const categories = new Set(configOptions.map((option) => option.category)); + const extras: SessionConfigOption[] = []; + + if (initialModel && !categories.has("model")) { + extras.push({ + id: "model", + name: "Model", + type: "select", + currentValue: initialModel, + options: [{ value: initialModel, name: initialModel }], + category: "model", + }); + } + + if (initialReasoningEffort && !categories.has("thought_level")) { + extras.push({ + id: adapter === "codex" ? "reasoning_effort" : "effort", + name: adapter === "codex" ? "Reasoning" : "Effort", + type: "select", + currentValue: initialReasoningEffort, + options: [ + { value: initialReasoningEffort, name: initialReasoningEffort }, + ], + category: "thought_level", + }); + } + + return extras.length > 0 ? [...configOptions, ...extras] : configOptions; +} diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index a0c12d0dda..ebb59b1caf 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -5,6 +5,8 @@ import type { ContentBlock, RequestPermissionRequest, SessionConfigOption, + SessionConfigSelectGroup, + SessionConfigSelectOption, SessionUpdate, } from "@agentclientprotocol/sdk"; import { @@ -57,6 +59,7 @@ import { getCloudRuntimeOptions, } from "./cloudRunOptions"; import { + addMissingCloudRuntimeConfigOptions, buildCloudDefaultConfigOptions, extractLatestConfigOptionsFromEntries, } from "./cloudSessionConfig"; @@ -4040,44 +4043,112 @@ export class SessionService { } const previewOptions = await entry.promise; + const session = this.d.store.getSessions()[taskRunId]; + if (!session || session.adapter !== adapter) return; + + const existingOptions = session.configOptions ?? []; + const existingModelOption = getConfigOptionByCategory( + existingOptions, + "model", + ); + const existingReasoningOption = getConfigOptionByCategory( + existingOptions, + "thought_level", + ); + const existingModel = existingModelOption?.currentValue; + const existingReasoningEffort = existingReasoningOption?.currentValue; + const preferredModel = + typeof existingModel === "string" ? existingModel : initialModel; + const preferredReasoningEffort = + typeof existingReasoningEffort === "string" + ? existingReasoningEffort + : initialReasoningEffort; + const applyPreferredValue = ( + option: SessionConfigOption, + preferredValue: string | undefined, + existingOption: SessionConfigOption | undefined, + ): SessionConfigOption => { + if (option.type !== "select" || !preferredValue) return option; + + const previewValues = flattenSelectOptions(option.options); + if (previewValues.some((value) => value.value === preferredValue)) { + return { ...option, currentValue: preferredValue }; + } + + const existingValues = + existingOption?.type === "select" + ? flattenSelectOptions(existingOption.options) + : []; + const reasoningLabels: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + }; + const selectedValue = existingValues.find( + (value) => value.value === preferredValue, + ) ?? { + value: preferredValue, + name: + option.category === "thought_level" + ? (reasoningLabels[preferredValue] ?? preferredValue) + : preferredValue, + }; + + if (option.options.length > 0 && "group" in option.options[0]) { + return { + ...option, + currentValue: preferredValue, + options: [ + ...(option.options as SessionConfigSelectGroup[]), + { + group: "selected", + name: "Selected", + options: [selectedValue], + }, + ], + }; + } + + return { + ...option, + currentValue: preferredValue, + options: [ + ...(option.options as SessionConfigSelectOption[]), + selectedValue, + ], + }; + }; const extras = previewOptions .filter( (opt) => opt.category === "model" || opt.category === "thought_level", ) .map((opt) => { - if ( - opt.category === "model" && - opt.type === "select" && - typeof initialModel === "string" - ) { - const flat = flattenSelectOptions(opt.options); - if (flat.some((o) => o.value === initialModel)) { - return { ...opt, currentValue: initialModel }; - } + if (opt.category === "model") { + return applyPreferredValue(opt, preferredModel, existingModelOption); } - if ( - opt.category === "thought_level" && - opt.type === "select" && - typeof initialReasoningEffort === "string" - ) { - const flat = flattenSelectOptions(opt.options); - if (flat.some((o) => o.value === initialReasoningEffort)) { - return { ...opt, currentValue: initialReasoningEffort }; - } + if (opt.category === "thought_level") { + return applyPreferredValue( + opt, + preferredReasoningEffort, + existingReasoningOption, + ); } return opt; }); if (extras.length === 0) return; - const session = this.d.store.getSessions()[taskRunId]; - if (!session) return; + const previewCategories = new Set(extras.map((option) => option.category)); + const merged = [ + ...existingOptions.filter( + (option) => !previewCategories.has(option.category), + ), + ...extras, + ]; - const existingOptions = session.configOptions ?? []; - const existingIds = new Set(existingOptions.map((o) => o.id)); - const newExtras = extras.filter((o) => !existingIds.has(o.id)); - if (newExtras.length === 0) return; - const merged = [...existingOptions, ...newExtras]; + if (JSON.stringify(existingOptions) === JSON.stringify(merged)) return; this.d.store.updateSession(taskRunId, { configOptions: merged }); } @@ -4136,8 +4207,23 @@ export class SessionService { if (shouldRefreshConfigOptions) { this.d.store.updateSession(existing.taskRunId, { adapter, - configOptions: buildCloudDefaultConfigOptions(currentMode, adapter), + configOptions: addMissingCloudRuntimeConfigOptions( + buildCloudDefaultConfigOptions(currentMode, adapter), + adapter, + initialModel, + initialReasoningEffort, + ), }); + } else { + const configOptions = addMissingCloudRuntimeConfigOptions( + existing.configOptions ?? [], + adapter, + initialModel, + initialReasoningEffort, + ); + if (configOptions !== existing.configOptions) { + this.d.store.updateSession(existing.taskRunId, { configOptions }); + } } void this.fetchAndApplyCloudPreviewOptions( existing.taskRunId, @@ -4222,9 +4308,11 @@ export class SessionService { session.status = "disconnected"; session.isCloud = true; session.adapter = adapter; - session.configOptions = buildCloudDefaultConfigOptions( - initialMode, + session.configOptions = addMissingCloudRuntimeConfigOptions( + buildCloudDefaultConfigOptions(initialMode, adapter), adapter, + initialModel, + initialReasoningEffort, ); this.d.store.setSession(session); // Optimistic seeding for the initial task description is deferred @@ -4243,10 +4331,22 @@ export class SessionService { )?.currentValue; const currentMode = typeof existingMode === "string" ? existingMode : initialMode; - updates.configOptions = buildCloudDefaultConfigOptions( - currentMode, + updates.configOptions = addMissingCloudRuntimeConfigOptions( + buildCloudDefaultConfigOptions(currentMode, adapter), adapter, + initialModel, + initialReasoningEffort, ); + } else { + const configOptions = addMissingCloudRuntimeConfigOptions( + existing.configOptions, + adapter, + initialModel, + initialReasoningEffort, + ); + if (configOptions !== existing.configOptions) { + updates.configOptions = configOptions; + } } if (Object.keys(updates).length > 0) { this.d.store.updateSession(existing.taskRunId, updates); diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 5d3f9022fb..241d012aa6 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -1,4 +1,8 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { + ContentBlock, + SessionConfigOption, + SessionConfigSelectGroup, +} from "@agentclientprotocol/sdk"; import type { AcpMessage } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore"; @@ -965,6 +969,42 @@ describe("SessionService", () => { ); }); + it("shows the selected cloud model and reasoning before preview config loads", () => { + const service = getSessionService(); + + service.watchCloudTask( + "task-runtime-123", + "run-runtime-123", + "https://api.example.com", + 7, + undefined, + undefined, + "auto", + "codex", + "gpt-5.6-sol", + undefined, + undefined, + undefined, + "max", + ); + + expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith( + expect.objectContaining({ + adapter: "codex", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + category: "model", + currentValue: "gpt-5.6-sol", + }), + expect.objectContaining({ + category: "thought_level", + currentValue: "max", + }), + ]), + }), + ); + }); + it("resets a same-run preloaded session before the first cloud snapshot", () => { const service = getSessionService(); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( @@ -3538,6 +3578,7 @@ describe("SessionService", () => { taskRunId: "run-model-123", taskId: "task-model-123", isCloud: true, + adapter: "claude", configOptions: [ { id: "mode", @@ -3547,6 +3588,27 @@ describe("SessionService", () => { currentValue: "plan", options: [], }, + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-sonnet-4-6", + options: [ + { + value: "claude-sonnet-4-6", + name: "claude-sonnet-4-6", + }, + ], + }, + { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "high", + options: [{ value: "high", name: "high" }], + }, ], }); mockSessionStoreSetters.getSessions.mockReturnValue({ @@ -3620,9 +3682,371 @@ describe("SessionService", () => { ); const modelOpt = modelUpdate?.[1].configOptions?.find( (o) => o.id === "model", - ) as { currentValue?: string } | undefined; + ) as + | { + currentValue?: string; + options?: Array<{ name: string; value: string }>; + } + | undefined; expect(modelOpt?.currentValue).toBe("claude-sonnet-4-6"); + expect(modelOpt?.options).toContainEqual({ + value: "claude-sonnet-4-6", + name: "Sonnet 4.6", + }); + }); + }); + + it("keeps model-specific max reasoning when generic preview options omit it", async () => { + const service = getSessionService(); + const session = createMockSession({ + taskRunId: "run-max-123", + taskId: "task-max-123", + isCloud: true, + adapter: "codex", + configOptions: [ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "auto", + options: [], + }, + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "gpt-5.6-sol", + options: [{ value: "gpt-5.6-sol", name: "gpt-5.6-sol" }], + }, + { + id: "reasoning_effort", + name: "Reasoning", + type: "select", + category: "thought_level", + currentValue: "max", + options: [{ value: "max", name: "Max" }], + }, + ], + }); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-max-123": session, + }); + mockTrpcAgent.getPreviewConfigOptions.query.mockResolvedValueOnce([ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "gpt-5.5", + options: [ + { value: "gpt-5.5", name: "gpt-5.5" }, + { value: "gpt-5.6-sol", name: "gpt-5.6-sol" }, + ], + }, + { + id: "reasoning_effort", + name: "Reasoning", + type: "select", + category: "thought_level", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "xhigh", name: "Extra High" }, + ], + }, + ]); + + service.watchCloudTask( + "task-max-123", + "run-max-123", + "https://api.example.com", + 7, + undefined, + undefined, + "auto", + "codex", + "gpt-5.6-sol", + undefined, + undefined, + undefined, + "max", + ); + + await vi.waitFor(() => { + const configUpdate = ( + mockSessionStoreSetters.updateSession.mock.calls as Array< + [string, { configOptions?: SessionConfigOption[] }] + > + ) + .filter(([runId]) => runId === "run-max-123") + .map(([, patch]) => patch.configOptions) + .find(Boolean); + const reasoningOption = configUpdate?.find( + (option) => option.category === "thought_level", + ); + expect(reasoningOption?.currentValue).toBe("max"); + expect( + reasoningOption?.type === "select" + ? reasoningOption.options + : undefined, + ).toContainEqual({ value: "max", name: "Max" }); + }); + }); + + it("keeps runtime controls omitted from a partial preview response", async () => { + const service = getSessionService(); + const reasoningOption: SessionConfigOption = { + id: "reasoning_effort", + name: "Reasoning", + type: "select", + category: "thought_level", + currentValue: "max", + options: [{ value: "max", name: "Max" }], + }; + const session = createMockSession({ + taskRunId: "run-partial-123", + taskId: "task-partial-123", + isCloud: true, + adapter: "codex", + configOptions: [ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "auto", + options: [], + }, + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "gpt-5.6-sol", + options: [{ value: "gpt-5.6-sol", name: "gpt-5.6-sol" }], + }, + reasoningOption, + ], + }); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-partial-123": session, + }); + mockTrpcAgent.getPreviewConfigOptions.query.mockResolvedValueOnce([ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "gpt-5.5", + options: [ + { value: "gpt-5.5", name: "gpt-5.5" }, + { value: "gpt-5.6-sol", name: "gpt-5.6-sol" }, + ], + }, + ]); + + service.watchCloudTask( + "task-partial-123", + "run-partial-123", + "https://api.example.com", + 7, + undefined, + undefined, + "auto", + "codex", + "gpt-5.6-sol", + undefined, + undefined, + undefined, + "max", + ); + + await vi.waitFor(() => { + const configUpdate = ( + mockSessionStoreSetters.updateSession.mock.calls as Array< + [string, { configOptions?: SessionConfigOption[] }] + > + ) + .filter(([runId]) => runId === "run-partial-123") + .map(([, patch]) => patch.configOptions) + .find(Boolean); + expect(configUpdate).toContainEqual(reasoningOption); + }); + }); + + it("adds a missing selected value to grouped preview options", async () => { + const service = getSessionService(); + mockGetConfigOptionByCategory.mockImplementation( + ( + configOptions: Array<{ category?: string }> | undefined, + category?: string, + ) => configOptions?.find((option) => option.category === category), + ); + const session = createMockSession({ + taskRunId: "run-grouped-123", + taskId: "task-grouped-123", + isCloud: true, + adapter: "codex", + configOptions: [ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "auto", + options: [], + }, + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "gpt-5.6-sol", + options: [{ value: "gpt-5.6-sol", name: "GPT-5.6 Sol" }], + }, + ], + }); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-grouped-123": session, + }); + mockTrpcAgent.getPreviewConfigOptions.query.mockResolvedValueOnce([ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "gpt-5.5", + options: [ + { + group: "openai", + name: "OpenAI", + options: [{ value: "gpt-5.5", name: "GPT-5.5" }], + }, + ], + }, + ]); + + service.watchCloudTask( + "task-grouped-123", + "run-grouped-123", + "https://api.example.com", + 7, + undefined, + undefined, + "auto", + "codex", + "gpt-5.6-sol", + ); + + await vi.waitFor(() => { + const configUpdate = ( + mockSessionStoreSetters.updateSession.mock.calls as Array< + [string, { configOptions?: SessionConfigOption[] }] + > + ) + .filter(([runId]) => runId === "run-grouped-123") + .map(([, patch]) => patch.configOptions) + .find(Boolean); + const modelOption = configUpdate?.find( + (option) => option.category === "model", + ); + expect(modelOption?.currentValue).toBe("gpt-5.6-sol"); + expect( + modelOption?.type === "select" && + modelOption.options.length > 0 && + "group" in modelOption.options[0] + ? (modelOption.options as SessionConfigSelectGroup[]).flatMap( + (group) => group.options, + ) + : undefined, + ).toContainEqual({ value: "gpt-5.6-sol", name: "GPT-5.6 Sol" }); + }); + }); + + it("does not rewrite unchanged cloud preview options", async () => { + const service = getSessionService(); + const previewOptions = [ + { + id: "model", + name: "Model", + type: "select" as const, + category: "model" as const, + currentValue: "gpt-5.6-sol", + options: [{ value: "gpt-5.6-sol", name: "gpt-5.6-sol" }], + }, + { + id: "reasoning_effort", + name: "Reasoning", + type: "select" as const, + category: "thought_level" as const, + currentValue: "max", + options: [{ value: "max", name: "Max" }], + }, + ]; + const session = createMockSession({ + taskRunId: "run-stable-123", + taskId: "task-stable-123", + isCloud: true, + adapter: "codex", + configOptions: [ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "auto", + options: [], + }, + ...previewOptions, + ], + }); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(session); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-stable-123": session, + }); + mockTrpcAgent.getPreviewConfigOptions.query.mockResolvedValueOnce([ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "auto", + options: [], + }, + ...previewOptions, + ]); + + service.watchCloudTask( + "task-stable-123", + "run-stable-123", + "https://api.example.com", + 7, + undefined, + undefined, + "auto", + "codex", + "gpt-5.6-sol", + undefined, + undefined, + undefined, + "max", + ); + + await vi.waitFor(() => { + expect( + mockTrpcAgent.getPreviewConfigOptions.query, + ).toHaveBeenCalledOnce(); }); + await Promise.resolve(); + + expect(mockSessionStoreSetters.updateSession).not.toHaveBeenCalledWith( + "run-stable-123", + expect.objectContaining({ configOptions: expect.any(Array) }), + ); }); it("retries an errored cloud watcher in place", async () => {