diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 7de8a5f2a..634a33180 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -79,20 +79,23 @@ export function useSessionConnection({ const adapter = task.latest_run.runtime_adapter === "codex" ? "codex" : "claude"; const initialModel = task.latest_run.model ?? undefined; - const cleanup = getSessionService().watchCloudTask( - task.id, + const initialReasoningEffort = + task.latest_run.reasoning_effort ?? undefined; + const cleanup = getSessionService().watchCloudTask({ + taskId: task.id, runId, - getCloudUrlFromRegion(cloudAuthState.cloudRegion), - cloudAuthState.projectId, - () => { + apiHost: getCloudUrlFromRegion(cloudAuthState.cloudRegion), + teamId: cloudAuthState.projectId, + onStatusChange: () => { queryClient.invalidateQueries({ queryKey: ["tasks"] }); }, - task.latest_run?.log_url, + logUrl: task.latest_run?.log_url, initialMode, adapter, initialModel, - task.description ?? undefined, - ); + taskDescription: task.description ?? undefined, + initialReasoningEffort, + }); return cleanup; }, [ cloudAuthState.bootstrapComplete, @@ -105,6 +108,7 @@ export function useSessionConnection({ task.latest_run?.id, task.latest_run?.log_url, task.latest_run?.model, + task.latest_run?.reasoning_effort, task.latest_run?.runtime_adapter, task.latest_run?.state?.initial_permission_mode, task.description, diff --git a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts b/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts index 617ad07f7..36b907c11 100644 --- a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts @@ -10,7 +10,7 @@ * Only the tRPC network boundary is faked, that boundary is the thing we simulate dropping. */ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockTrpcAgent = vi.hoisted(() => ({ start: { mutate: vi.fn() }, @@ -343,19 +343,22 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("recovers a stranded queue after an idle resumed run drops to disconnected", async () => { const service = getSessionService(); // Subscribe (captures the onUpdate.onData channel) without letting the // async hydrate clobber the state we control below. - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); const onData = latestOnData(); // Start: agent booting, not yet ready (mirrors a snapshot-resume run @@ -442,14 +445,13 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { it("drains a queue stranded on an idle disconnected run via the real retry path (no injected status update)", async () => { const service = getSessionService(); - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); // An idle, already-bootstrapped run that completed its turn for THIS run // (live idle flag set) then dropped to disconnected on an SSE blip. The @@ -493,14 +495,13 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { it("does not drain while the agent is still booting (boot race protected)", async () => { const service = getSessionService(); - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); const onData = latestOnData(); // Disconnected, queued message, but the agent has NEVER booted for this @@ -535,14 +536,13 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { it("does not drain on a current-run run_started snapshot until turn_complete (initial/resume turn race)", async () => { const service = getSessionService(); - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); const onData = latestOnData(); // Disconnected, queued message. The agent has NOT completed a turn for @@ -637,14 +637,13 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { it("does not dispatch a queued follow-up mid-turn after retryCloudTaskWatch clears isPromptPending", async () => { const service = getSessionService(); - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); const onData = latestOnData(); // Agent booted and idle from a prior turn. @@ -756,14 +755,13 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { it("clears the idle marker when sendCloudPrompt starts a turn even if the session/prompt log never arrives", async () => { const service = getSessionService(); - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); // Agent booted and idle from a prior turn. sessionStoreSetters.setSession( @@ -864,14 +862,13 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { it("does not recover from a prior run's turn_complete carried into the resumed session", async () => { const service = getSessionService(); - service.watchCloudTask( - TASK_ID, - RUN_ID, - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run", - ); + service.watchCloudTask({ + taskId: TASK_ID, + runId: RUN_ID, + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run", + }); const onData = latestOnData(); // resumeCloudRun copies the PREVIOUS run's history into the new run's diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index 22da3ac81..e8726fe84 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -1,7 +1,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { AgentSession } from "@features/sessions/stores/sessionStore"; import type { Task } from "@shared/types"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- Hoisted Mocks --- @@ -401,6 +401,10 @@ describe("SessionService", () => { ); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe("singleton management", () => { it("returns the same instance on multiple calls", () => { const instance1 = getSessionService(); @@ -755,16 +759,14 @@ describe("SessionService", () => { it("builds codex cloud mode options using native codex modes", () => { const service = getSessionService(); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - undefined, - "full-access", - "codex", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + initialMode: "full-access", + adapter: "codex", + }); expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -794,12 +796,12 @@ describe("SessionService", () => { }), ); - service.watchCloudTask( - "task-123", - "run-123", - "https://app.example.com", - 2, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://app.example.com", + teamId: 2, + }); expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -820,12 +822,12 @@ describe("SessionService", () => { it("subscribes to cloud updates before starting the watcher", async () => { const service = getSessionService(); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); expect(mockTrpcCloudTask.onUpdate.subscribe).toHaveBeenCalledWith( { taskId: "task-123", runId: "run-123" }, @@ -856,12 +858,12 @@ describe("SessionService", () => { unsubscribe, }); - const cleanup = service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + const cleanup = service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); cleanup(); expect(unsubscribe).not.toHaveBeenCalled(); @@ -875,19 +877,19 @@ describe("SessionService", () => { unsubscribe, }); - const firstCleanup = service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + const firstCleanup = service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); firstCleanup(); - const secondCleanup = service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + const secondCleanup = service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); expect(unsubscribe).not.toHaveBeenCalled(); expect(mockTrpcCloudTask.watch.mutate).toHaveBeenCalledTimes(1); @@ -900,19 +902,19 @@ describe("SessionService", () => { const service = getSessionService(); const onStatusChange = vi.fn(); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, onStatusChange, - ); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + }); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -964,14 +966,13 @@ describe("SessionService", () => { ); mockTrpcLogs.writeLocalLogs.mutate.mockResolvedValue(undefined); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -1014,14 +1015,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([inFlightPrompt]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -1078,14 +1078,13 @@ describe("SessionService", () => { promptResponse, ]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -1143,14 +1142,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([turnCompleteEvent]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledWith( @@ -1196,14 +1194,13 @@ describe("SessionService", () => { result: { stopReason: "end_turn" }, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1256,14 +1253,13 @@ describe("SessionService", () => { "run-123": sessionWithQueue, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1327,14 +1323,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([turnCompleteEvent]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect( @@ -1397,14 +1392,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([turnCompleteEvent]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -1468,14 +1462,13 @@ describe("SessionService", () => { result: { stopReason: "end_turn" }, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1579,14 +1572,13 @@ describe("SessionService", () => { result: { stopReason: "end_turn" }, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1657,14 +1649,13 @@ describe("SessionService", () => { "run-123": disconnectedSession, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1733,14 +1724,13 @@ describe("SessionService", () => { "run-123": disconnectedSession, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1815,14 +1805,13 @@ describe("SessionService", () => { "run-123": disconnectedSession, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1874,14 +1863,13 @@ describe("SessionService", () => { "run-123": disconnectedSession, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -1935,14 +1923,13 @@ describe("SessionService", () => { "run-123": bootingSession, }); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { @@ -2014,14 +2001,13 @@ describe("SessionService", () => { completion, ]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -2071,12 +2057,12 @@ describe("SessionService", () => { Array.from({ length: 14 }, () => storedLine).join("\n"), ); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { onData: (update: unknown) => void; @@ -2152,12 +2138,12 @@ describe("SessionService", () => { Array.from({ length: 14 }, () => storedLine).join("\n"), ); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { onData: (update: unknown) => void; @@ -2249,12 +2235,12 @@ describe("SessionService", () => { })), ); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); const subscribeOptions = mockTrpcCloudTask.onUpdate.subscribe.mock .calls[0][1] as { onData: (update: unknown) => void; @@ -2342,14 +2328,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([runStartedEvent]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -2400,14 +2385,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([runStartedEvent]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); await vi.waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -2454,14 +2438,13 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([runStartedEvent]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + }); // Wait long enough for the hydration callback to run; assert the // store was never told to set status: "connected" again. @@ -2490,18 +2473,15 @@ describe("SessionService", () => { mockTrpcLogs.fetchS3Logs.query.mockResolvedValue(""); mockTrpcLogs.writeLocalLogs.mutate.mockResolvedValue(undefined); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - undefined, - "claude", - undefined, - "build me a thing", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + adapter: "claude", + taskDescription: "build me a thing", + }); await vi.waitFor(() => { expect( @@ -2561,18 +2541,15 @@ describe("SessionService", () => { lifecycleNotification, ]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - undefined, - "claude", - undefined, - "build me a thing", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + adapter: "claude", + taskDescription: "build me a thing", + }); await vi.waitFor(() => { expect( @@ -2630,18 +2607,15 @@ describe("SessionService", () => { }; mockConvertStoredEntriesToEvents.mockReturnValueOnce([priorPrompt]); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - undefined, - "https://logs.example.com/run-123", - undefined, - "claude", - undefined, - "hello there", - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + logUrl: "https://logs.example.com/run-123", + adapter: "claude", + taskDescription: "hello there", + }); // Wait for hydration to run. await vi.waitFor(() => { @@ -2673,19 +2647,19 @@ describe("SessionService", () => { }), ); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); service.stopCloudTaskWatch("task-123"); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); resolveSecondWatchStart(); await Promise.resolve(); @@ -2710,12 +2684,12 @@ describe("SessionService", () => { }), ); - service.watchCloudTask( - "task-123", - "run-123", - "https://api.anthropic.com", - 123, - ); + service.watchCloudTask({ + taskId: "task-123", + runId: "run-123", + apiHost: "https://api.anthropic.com", + teamId: 123, + }); service.stopCloudTaskWatch("task-123"); expect(mockTrpcCloudTask.unwatch.mutate).not.toHaveBeenCalled(); @@ -2779,21 +2753,24 @@ describe("SessionService", () => { type: "select", category: "thought_level", currentValue: "high", - options: [], + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], }, ]); - service.watchCloudTask( - "task-model-123", - "run-model-123", - "https://api.example.com", - 7, - undefined, - undefined, - undefined, - "claude", - "claude-sonnet-4-6", - ); + service.watchCloudTask({ + taskId: "task-model-123", + runId: "run-model-123", + apiHost: "https://api.example.com", + teamId: 7, + adapter: "claude", + initialModel: "claude-sonnet-4-6", + initialReasoningEffort: "max", + }); await vi.waitFor(() => { expect( @@ -2822,6 +2799,74 @@ describe("SessionService", () => { (o) => o.id === "model", ) as { currentValue?: string } | undefined; expect(modelOpt?.currentValue).toBe("claude-sonnet-4-6"); + const effortOpt = modelUpdate?.[1].configOptions?.find( + (o) => o.id === "effort", + ) as { currentValue?: string } | undefined; + expect(effortOpt?.currentValue).toBe("max"); + }); + }); + + it("leaves the default effort untouched when initialReasoningEffort is not in the option list", async () => { + const service = getSessionService(); + + const sessionAfterInit = createMockSession({ + taskRunId: "run-effort-xhigh", + taskId: "task-effort-xhigh", + isCloud: true, + configOptions: [ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "plan", + options: [], + }, + ], + }); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-effort-xhigh": sessionAfterInit, + }); + + mockTrpcAgent.getPreviewConfigOptions.query.mockResolvedValueOnce([ + { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, + ]); + + service.watchCloudTask({ + taskId: "task-effort-xhigh", + runId: "run-effort-xhigh", + apiHost: "https://api.example.com", + teamId: 7, + adapter: "claude", + initialReasoningEffort: "xhigh", + }); + + await vi.waitFor(() => { + const calls = mockSessionStoreSetters.updateSession.mock.calls as Array< + [string, { configOptions?: Array<{ id: string }> }] + >; + const update = calls.find( + ([runId, patch]) => + runId === "run-effort-xhigh" && + patch.configOptions?.some((o) => o.id === "effort"), + ); + expect(update).toBeTruthy(); + const effortOpt = update?.[1].configOptions?.find( + (o) => o.id === "effort", + ) as { currentValue?: string } | undefined; + expect(effortOpt?.currentValue).toBe("high"); }); }); @@ -3434,6 +3479,168 @@ describe("SessionService", () => { ); }); + it("preserves prior reasoning effort when sendPrompt creates a new cloud run", async () => { + const service = getSessionService(); + const watchSpy = vi + .spyOn(service, "watchCloudTask") + .mockImplementation(() => () => {}); + + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "completed", + cloudBranch: "feature/cloud-run", + adapter: "claude", + configOptions: [ + { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "max", + options: [], + }, + ], + }), + ); + mockGetConfigOptionByCategory.mockImplementation( + ( + configOptions: Array<{ category?: string }> | undefined, + category?: string, + ) => configOptions?.find((opt) => opt.category === category), + ); + mockAuthenticatedClient.getTaskRun.mockResolvedValue({ + id: "run-123", + task: "task-123", + team: 123, + branch: "feature/cloud-run", + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: null, + environment: "cloud", + status: "completed", + log_url: "https://example.com/logs/run-123", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + completed_at: "2026-04-14T00:05:00Z", + }); + mockAuthenticatedClient.getTask.mockResolvedValue(createMockTask()); + mockAuthenticatedClient.runTaskInCloud.mockResolvedValue( + createMockTask({ + latest_run: { + id: "run-456", + task: "task-123", + team: 123, + branch: "feature/cloud-run", + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: null, + environment: "cloud", + status: "queued", + log_url: "https://example.com/logs/run-456", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:06:00Z", + updated_at: "2026-04-14T00:06:00Z", + completed_at: null, + }, + }), + ); + + await service.sendPrompt("task-123", "follow up"); + + const newRunCall = watchSpy.mock.calls.find( + ([opts]) => opts.runId === "run-456", + ); + expect(newRunCall).toBeDefined(); + expect(newRunCall?.[0].initialReasoningEffort).toBe("max"); + }); + + it("uses newRun.reasoning_effort over prior effort when starting a new cloud run", async () => { + const service = getSessionService(); + const watchSpy = vi + .spyOn(service, "watchCloudTask") + .mockImplementation(() => () => {}); + + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "completed", + cloudBranch: "feature/cloud-run", + adapter: "claude", + configOptions: [ + { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "high", + options: [], + }, + ], + }), + ); + mockGetConfigOptionByCategory.mockImplementation( + ( + configOptions: Array<{ category?: string }> | undefined, + category?: string, + ) => configOptions?.find((opt) => opt.category === category), + ); + mockAuthenticatedClient.getTaskRun.mockResolvedValue({ + id: "run-123", + task: "task-123", + team: 123, + branch: "feature/cloud-run", + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: "high", + environment: "cloud", + status: "completed", + log_url: "https://example.com/logs/run-123", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + completed_at: "2026-04-14T00:05:00Z", + }); + mockAuthenticatedClient.getTask.mockResolvedValue(createMockTask()); + mockAuthenticatedClient.runTaskInCloud.mockResolvedValue( + createMockTask({ + latest_run: { + id: "run-456", + task: "task-123", + team: 123, + branch: "feature/cloud-run", + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: "max", + environment: "cloud", + status: "queued", + log_url: "https://example.com/logs/run-456", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:06:00Z", + updated_at: "2026-04-14T00:06:00Z", + completed_at: null, + }, + }), + ); + + await service.sendPrompt("task-123", "follow up"); + + const newRunCall = watchSpy.mock.calls.find( + ([opts]) => opts.runId === "run-456", + ); + expect(newRunCall).toBeDefined(); + expect(newRunCall?.[0].initialReasoningEffort).toBe("max"); + }); + it("preserves attachment blocks in the optimistic resume event", async () => { const service = getSessionService(); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index efa33cc25..dfb6fa275 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -224,6 +224,20 @@ export interface ConnectParams { reasoningLevel?: string; } +export interface WatchCloudTaskOptions { + taskId: string; + runId: string; + apiHost: string; + teamId: number; + onStatusChange?: () => void; + logUrl?: string; + initialMode?: string; + adapter?: Adapter; + initialModel?: string; + taskDescription?: string; + initialReasoningEffort?: string; +} + // --- Singleton Service Instance --- let serviceInstance: SessionService | null = null; @@ -1766,16 +1780,14 @@ export class SessionService { throw new Error("Authentication required for cloud commands"); } - this.watchCloudTask( - session.taskId, - session.taskRunId, - cloudCommandAuth.apiHost, - cloudCommandAuth.teamId, - undefined, - session.logUrl, - undefined, - session.adapter ?? "claude", - ); + this.watchCloudTask({ + taskId: session.taskId, + runId: session.taskRunId, + apiHost: cloudCommandAuth.apiHost, + teamId: cloudCommandAuth.teamId, + logUrl: session.logUrl, + adapter: session.adapter ?? "claude", + }); const artifactIds = await uploadRunAttachments( auth.client, @@ -2020,17 +2032,24 @@ export class SessionService { )?.currentValue; const initialModel = newRun.model ?? (typeof priorModel === "string" ? priorModel : undefined); - this.watchCloudTask( - session.taskId, - newRun.id, - auth.apiHost, - auth.teamId, - undefined, - newRun.log_url, + const priorEffort = getConfigOptionByCategory( + session.configOptions, + "thought_level", + )?.currentValue; + const initialReasoningEffort = + newRun.reasoning_effort ?? + (typeof priorEffort === "string" ? priorEffort : undefined); + this.watchCloudTask({ + taskId: session.taskId, + runId: newRun.id, + apiHost: auth.apiHost, + teamId: auth.teamId, + logUrl: newRun.log_url, initialMode, - newRun.runtime_adapter ?? session.adapter ?? "claude", + adapter: newRun.runtime_adapter ?? session.adapter ?? "claude", initialModel, - ); + initialReasoningEffort, + }); // Invalidate task queries so the UI picks up the new run metadata queryClient.invalidateQueries({ queryKey: ["tasks"] }); @@ -2534,6 +2553,7 @@ export class SessionService { apiHost: string, adapter: Adapter, initialModel?: string, + initialReasoningEffort?: string, ): Promise { const cacheKey = `${apiHost}::${adapter}`; let pending = this.previewConfigOptionsCache.get(cacheKey); @@ -2568,6 +2588,16 @@ export class SessionService { return { ...opt, currentValue: initialModel }; } } + 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 }; + } + } return opt; }); @@ -2593,18 +2623,20 @@ export class SessionService { * status triggers full teardown from within handleCloudTaskUpdate via * stopCloudTaskWatch(). */ - watchCloudTask( - taskId: string, - runId: string, - apiHost: string, - teamId: number, - onStatusChange?: () => void, - logUrl?: string, - initialMode?: string, - adapter: Adapter = "claude", - initialModel?: string, - taskDescription?: string, - ): () => void { + watchCloudTask(options: WatchCloudTaskOptions): () => void { + const { + taskId, + runId, + apiHost, + teamId, + onStatusChange, + logUrl, + initialMode, + adapter = "claude", + initialModel, + taskDescription, + initialReasoningEffort, + } = options; const taskRunId = runId; const existingWatcher = this.cloudTaskWatchers.get(taskId); @@ -2640,6 +2672,7 @@ export class SessionService { apiHost, adapter, initialModel, + initialReasoningEffort, ); } return () => {}; @@ -2713,6 +2746,7 @@ export class SessionService { apiHost, adapter, initialModel, + initialReasoningEffort, ); if (shouldHydrateSession) { @@ -2950,7 +2984,12 @@ export class SessionService { toast.error( err instanceof Error ? err.message : "Handoff to local failed", ); - this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); + this.watchCloudTask({ + taskId, + runId, + apiHost: auth.apiHost, + teamId: auth.projectId, + }); sessionStoreSetters.updateSession(runId, { handoffInProgress: false, status: "disconnected", @@ -3015,7 +3054,12 @@ export class SessionService { processedLineCount: result.logEntryCount ?? 0, }); - this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); + this.watchCloudTask({ + taskId, + runId, + apiHost: auth.apiHost, + teamId: auth.projectId, + }); await Promise.all([ queryClient.refetchQueries({ queryKey: ["tasks"] }), queryClient.refetchQueries(trpc.workspace.getAll.pathFilter()),