From e96184e3eefb93b372b919f40c9abe52ca2722b4 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 26 Jun 2026 11:43:09 +0100 Subject: [PATCH 1/2] feat(mobile): warm sandbox on the selected model and runtime (port #2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warm-sandbox request only carried repository/github_integration/branch, so the backend could provision the sandbox on a default runtime/model rather than the one the user selected — wasting the warmed sandbox on submit. Thread the selected runtime adapter, model, and reasoning effort through warmTask and useWarmTask, and fold them into the warm dedup key so a model or reasoning change re-warms. The composer passes the currently-selected model, runtime ("claude"), and reasoning effort. Ports desktop PR #2936 to apps/mobile. Generated-By: PostHog Code Task-Id: 55143e04-70fc-4662-b23c-927d6697cf0a --- apps/mobile/src/app/task/index.tsx | 3 ++ apps/mobile/src/features/tasks/api.ts | 6 +++ .../src/features/tasks/api.warm.test.ts | 37 +++++++++++++++ .../features/tasks/hooks/useWarmTask.test.tsx | 46 +++++++++++++++++++ .../src/features/tasks/hooks/useWarmTask.ts | 28 ++++++++++- 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 3b0b0a2c3..d2da28fc9 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -383,6 +383,9 @@ export default function NewTaskScreen() { repository: selection.repository, githubIntegrationId: selection.integrationId, composerIsEmpty: !hasContent, + runtimeAdapter: "claude", + model, + reasoningEffort: showReasoningPill ? reasoning : null, }); if (isLoading && hasGithubIntegration === null) { diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index cd399b109..0bc88a4d9 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -306,6 +306,9 @@ export async function warmTask(options: { repository: string; github_integration: number; branch?: string | null; + runtime_adapter?: string | null; + model?: string | null; + reasoning_effort?: string | null; }): Promise<{ task_id: string; run_id: string } | null> { const baseUrl = getBaseUrl(); const projectId = getProjectId(); @@ -318,6 +321,9 @@ export async function warmTask(options: { repository: options.repository, github_integration: options.github_integration, branch: options.branch ?? null, + runtime_adapter: options.runtime_adapter ?? null, + model: options.model ?? null, + reasoning_effort: options.reasoning_effort ?? null, }), }, ); diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts index 16ab7f479..d8e8f882e 100644 --- a/apps/mobile/src/features/tasks/api.warm.test.ts +++ b/apps/mobile/src/features/tasks/api.warm.test.ts @@ -51,6 +51,40 @@ describe("warmTask", () => { repository: "posthog/posthog", github_integration: 7, branch: "main", + runtime_adapter: null, + model: null, + reasoning_effort: null, + }), + }), + ); + }); + + it("forwards the selected runtime, model, and reasoning effort", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { + status: 200, + }), + ); + + await warmTask({ + repository: "posthog/posthog", + github_integration: 7, + branch: "main", + runtime_adapter: "claude", + model: "claude-opus-4-8", + reasoning_effort: "high", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/projects/42/tasks/warm/", + expect.objectContaining({ + body: JSON.stringify({ + repository: "posthog/posthog", + github_integration: 7, + branch: "main", + runtime_adapter: "claude", + model: "claude-opus-4-8", + reasoning_effort: "high", }), }), ); @@ -72,6 +106,9 @@ describe("warmTask", () => { repository: "posthog/posthog", github_integration: 7, branch: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, }), }), ); diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 7765c76be..5d1458423 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -29,6 +29,9 @@ interface Props { githubIntegrationId?: number | null; branch?: string | null; composerIsEmpty: boolean; + runtimeAdapter?: string | null; + model?: string | null; + reasoningEffort?: string | null; } const composing: Props = { @@ -38,6 +41,12 @@ const composing: Props = { composerIsEmpty: false, }; +const NULL_RUNTIME = { + runtime_adapter: null, + model: null, + reasoning_effort: null, +}; + function render(initial: Props) { let current = initial; function Wrapper() { @@ -87,6 +96,7 @@ describe("useWarmTask", () => { repository: "acme/repo", github_integration: 42, branch: "main", + ...NULL_RUNTIME, }); }); @@ -161,11 +171,47 @@ describe("useWarmTask", () => { repository: expectedRepository, github_integration: 42, branch: expectedBranch, + ...NULL_RUNTIME, }); expect(mockWarmTask).toHaveBeenCalledTimes(2); }, ); + it("forwards the selected runtime and re-warms when the model changes", async () => { + const { rerender } = render({ + ...composing, + runtimeAdapter: "claude", + model: "claude-opus-4-8", + reasoningEffort: "high", + }); + await flushDebounce(); + expect(mockWarmTask).toHaveBeenLastCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + runtime_adapter: "claude", + model: "claude-opus-4-8", + reasoning_effort: "high", + }); + + rerender({ + ...composing, + runtimeAdapter: "claude", + model: "claude-sonnet-4-6", + reasoningEffort: "high", + }); + await flushDebounce(); + expect(mockWarmTask).toHaveBeenLastCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: "high", + }); + expect(mockWarmTask).toHaveBeenCalledTimes(2); + }); + it("warms again for a new selection after a failed warm", async () => { mockWarmTask.mockRejectedValueOnce(new Error("boom")); const { rerender } = render(composing); diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index 0173ed952..e69ce6555 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -13,6 +13,9 @@ interface UseWarmTaskOptions { githubIntegrationId?: number | null; branch?: string | null; composerIsEmpty: boolean; + runtimeAdapter?: string | null; + model?: string | null; + reasoningEffort?: string | null; } export function useWarmTask({ @@ -20,6 +23,9 @@ export function useWarmTask({ githubIntegrationId, branch, composerIsEmpty, + runtimeAdapter, + model, + reasoningEffort, }: UseWarmTaskOptions): void { const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG); @@ -27,6 +33,9 @@ export function useWarmTask({ const lastWarmedKeyRef = useRef(null); const normalizedBranch = branch ?? null; + const normalizedRuntimeAdapter = runtimeAdapter ?? null; + const normalizedModel = model ?? null; + const normalizedReasoningEffort = reasoningEffort ?? null; const eligible = !!enabled && !!repository && @@ -34,7 +43,7 @@ export function useWarmTask({ !composerIsEmpty; const key = repository && githubIntegrationId != null - ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}` + ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}` : null; useEffect(() => { @@ -56,6 +65,9 @@ export function useWarmTask({ const repo = repository; const githubIntegration = githubIntegrationId; const warmBranch = normalizedBranch; + const warmRuntimeAdapter = normalizedRuntimeAdapter; + const warmModel = normalizedModel; + const warmReasoningEffort = normalizedReasoningEffort; debounceRef.current = setTimeout(() => { debounceRef.current = null; lastWarmedKeyRef.current = key; @@ -63,6 +75,9 @@ export function useWarmTask({ repository: repo, github_integration: githubIntegration, branch: warmBranch, + runtime_adapter: warmRuntimeAdapter, + model: warmModel, + reasoning_effort: warmReasoningEffort, }).catch((error) => { lastWarmedKeyRef.current = null; log.warn("Failed to warm task", error); @@ -70,5 +85,14 @@ export function useWarmTask({ }, WARM_DEBOUNCE_MS); return clearDebounce; - }, [eligible, key, repository, githubIntegrationId, normalizedBranch]); + }, [ + eligible, + key, + repository, + githubIntegrationId, + normalizedBranch, + normalizedRuntimeAdapter, + normalizedModel, + normalizedReasoningEffort, + ]); } From c4f9b5edf335276d384433f8aaf1445d1a006c5f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 26 Jun 2026 12:12:40 +0100 Subject: [PATCH 2/2] test(mobile): fold model re-warm case into the parameterized table The standalone "re-warms when the model changes" test duplicated the existing it.each re-warm block. Fold the model case into that table with a per-case initial-props override and full expected payload, keeping the re-warm-on-field-change behavior covered once. Generated-By: PostHog Code Task-Id: 55143e04-70fc-4662-b23c-927d6697cf0a --- .../features/tasks/hooks/useWarmTask.test.tsx | 86 ++++++++----------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 5d1458423..8ed33df81 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -141,77 +141,63 @@ describe("useWarmTask", () => { it.each<{ name: string; + initial?: Partial; change: Partial; - expectedRepository: string; - expectedBranch: string; + expected: Record; }>([ { name: "repository", change: { repository: "acme/other" }, - expectedRepository: "acme/other", - expectedBranch: "main", + expected: { + repository: "acme/other", + github_integration: 42, + branch: "main", + ...NULL_RUNTIME, + }, }, { name: "branch", change: { branch: "feature/x" }, - expectedRepository: "acme/repo", - expectedBranch: "feature/x", + expected: { + repository: "acme/repo", + github_integration: 42, + branch: "feature/x", + ...NULL_RUNTIME, + }, + }, + { + name: "model", + initial: { + runtimeAdapter: "claude", + model: "claude-opus-4-8", + reasoningEffort: "high", + }, + change: { model: "claude-sonnet-4-6" }, + expected: { + repository: "acme/repo", + github_integration: 42, + branch: "main", + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: "high", + }, }, ])( "warms the new selection when the $name changes", - async ({ change, expectedRepository, expectedBranch }) => { - const { rerender } = render(composing); + async ({ initial, change, expected }) => { + const base = { ...composing, ...initial }; + const { rerender } = render(base); await flushDebounce(); expect(mockWarmTask).toHaveBeenCalledOnce(); - rerender({ ...composing, ...change }); + rerender({ ...base, ...change }); await flushDebounce(); - expect(mockWarmTask).toHaveBeenLastCalledWith({ - repository: expectedRepository, - github_integration: 42, - branch: expectedBranch, - ...NULL_RUNTIME, - }); + expect(mockWarmTask).toHaveBeenLastCalledWith(expected); expect(mockWarmTask).toHaveBeenCalledTimes(2); }, ); - it("forwards the selected runtime and re-warms when the model changes", async () => { - const { rerender } = render({ - ...composing, - runtimeAdapter: "claude", - model: "claude-opus-4-8", - reasoningEffort: "high", - }); - await flushDebounce(); - expect(mockWarmTask).toHaveBeenLastCalledWith({ - repository: "acme/repo", - github_integration: 42, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }); - - rerender({ - ...composing, - runtimeAdapter: "claude", - model: "claude-sonnet-4-6", - reasoningEffort: "high", - }); - await flushDebounce(); - expect(mockWarmTask).toHaveBeenLastCalledWith({ - repository: "acme/repo", - github_integration: 42, - branch: "main", - runtime_adapter: "claude", - model: "claude-sonnet-4-6", - reasoning_effort: "high", - }); - expect(mockWarmTask).toHaveBeenCalledTimes(2); - }); - it("warms again for a new selection after a failed warm", async () => { mockWarmTask.mockRejectedValueOnce(new Error("boom")); const { rerender } = render(composing);