From a7ee881b4521d12ce5bfdae263bb6297412e449f Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 7 Apr 2026 16:50:07 +0200 Subject: [PATCH] feat: cloud to local handoff --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/services/agent/service.ts | 13 + .../services/handoff/handoff-saga.test.ts | 340 ++++++++++++++++++ .../src/main/services/handoff/handoff-saga.ts | 202 +++++++++++ .../code/src/main/services/handoff/schemas.ts | 61 ++++ .../src/main/services/handoff/service.test.ts | 144 ++++++++ .../code/src/main/services/handoff/service.ts | 179 +++++++++ apps/code/src/main/trpc/router.ts | 2 + apps/code/src/main/trpc/routers/handoff.ts | 40 +++ .../src/renderer/components/HeaderRow.tsx | 2 +- .../components/CloudGitInteractionHeader.tsx | 42 ++- .../panels/components/LeafNodeRenderer.tsx | 5 +- .../sessions/hooks/useSessionCallbacks.ts | 53 +++ .../sessions/hooks/useSessionViewState.ts | 5 +- .../features/sessions/service/service.ts | 111 ++++++ .../features/sessions/stores/sessionStore.ts | 2 + .../task-detail/components/ChangesPanel.tsx | 5 +- .../task-detail/components/FileTreePanel.tsx | 5 +- .../components/TabContentRenderer.tsx | 6 +- .../workspace/hooks/useIsCloudTask.ts | 6 + packages/agent/package.json | 8 + packages/agent/src/resume.ts | 51 +++ packages/agent/src/server/agent-server.ts | 58 +-- packages/agent/tsup.config.ts | 2 + 25 files changed, 1261 insertions(+), 84 deletions(-) create mode 100644 apps/code/src/main/services/handoff/handoff-saga.test.ts create mode 100644 apps/code/src/main/services/handoff/handoff-saga.ts create mode 100644 apps/code/src/main/services/handoff/schemas.ts create mode 100644 apps/code/src/main/services/handoff/service.test.ts create mode 100644 apps/code/src/main/services/handoff/service.ts create mode 100644 apps/code/src/main/trpc/routers/handoff.ts create mode 100644 apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index f45163c53..b1deabc71 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -28,6 +28,7 @@ import { FoldersService } from "../services/folders/service"; import { FsService } from "../services/fs/service"; import { GitService } from "../services/git/service"; import { GitHubIntegrationService } from "../services/github-integration/service"; +import { HandoffService } from "../services/handoff/service"; import { LinearIntegrationService } from "../services/linear-integration/service"; import { LlmGatewayService } from "../services/llm-gateway/service"; import { McpAppsService } from "../services/mcp-apps/service"; @@ -88,6 +89,7 @@ container .bind(MAIN_TOKENS.GitHubIntegrationService) .to(GitHubIntegrationService); container.bind(MAIN_TOKENS.GitService).to(GitService); +container.bind(MAIN_TOKENS.HandoffService).to(HandoffService); container .bind(MAIN_TOKENS.LinearIntegrationService) .to(LinearIntegrationService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 27bdbcafc..db21224da 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -39,6 +39,7 @@ export const MAIN_TOKENS = Object.freeze({ FoldersService: Symbol.for("Main.FoldersService"), FsService: Symbol.for("Main.FsService"), GitService: Symbol.for("Main.GitService"), + HandoffService: Symbol.for("Main.HandoffService"), GitHubIntegrationService: Symbol.for("Main.GitHubIntegrationService"), LinearIntegrationService: Symbol.for("Main.LinearIntegrationService"), DeepLinkService: Symbol.for("Main.DeepLinkService"), diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index e5bcf5cd8..f52d779f3 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1017,6 +1017,19 @@ When creating pull requests, add the following footer at the end of the PR descr ]); } + setPendingContext(taskRunId: string, context: string): void { + const session = this.sessions.get(taskRunId); + if (!session) { + log.warn("Session not found for setPendingContext", { taskRunId }); + return; + } + session.pendingContext = context; + log.info("Set pending context on session", { + taskRunId, + contextLength: context.length, + }); + } + /** * Notify a session of a context change (CWD moved, detached HEAD, etc). * Used when focusing/unfocusing worktrees - the agent doesn't need to respawn diff --git a/apps/code/src/main/services/handoff/handoff-saga.test.ts b/apps/code/src/main/services/handoff/handoff-saga.test.ts new file mode 100644 index 000000000..9d89332de --- /dev/null +++ b/apps/code/src/main/services/handoff/handoff-saga.test.ts @@ -0,0 +1,340 @@ +import type { TreeSnapshotEvent } from "@posthog/agent/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HandoffSagaDeps, HandoffSagaInput } from "./handoff-saga"; +import { HandoffSaga } from "./handoff-saga"; + +const mockResumeFromLog = vi.hoisted(() => vi.fn()); +const mockFormatConversation = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/agent/resume", () => ({ + resumeFromLog: mockResumeFromLog, + formatConversationForResume: mockFormatConversation, +})); + +function createInput( + overrides: Partial = {}, +): HandoffSagaInput { + return { + taskId: "task-1", + runId: "run-1", + repoPath: "/repo", + apiHost: "https://us.posthog.com", + teamId: 2, + ...overrides, + }; +} + +function createSnapshot( + overrides: Partial = {}, +): TreeSnapshotEvent { + return { + treeHash: "abc123", + baseCommit: "def456", + archiveUrl: "https://s3.example.com/archive.tar.gz", + changes: [{ path: "test.txt", status: "A" }], + timestamp: "2026-04-07T00:00:00Z", + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}): HandoffSagaDeps { + return { + createApiClient: vi.fn().mockReturnValue({ + getTaskRun: vi.fn().mockResolvedValue({ + log_url: "https://logs.example.com/run-1.ndjson", + }), + }), + applyTreeSnapshot: vi.fn().mockResolvedValue(undefined), + updateWorkspaceMode: vi.fn(), + reconnectSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + channel: "ch-1", + }), + closeCloudRun: vi.fn().mockResolvedValue(undefined), + seedLocalLogs: vi.fn().mockResolvedValue(undefined), + killSession: vi.fn().mockResolvedValue(undefined), + setPendingContext: vi.fn(), + onProgress: vi.fn(), + ...overrides, + }; +} + +describe("HandoffSaga", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFormatConversation.mockReturnValue("conversation summary"); + }); + + it("completes happy path with snapshot", async () => { + const snapshot = createSnapshot(); + mockResumeFromLog.mockResolvedValue({ + conversation: [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ], + latestSnapshot: snapshot, + snapshotApplied: false, + interrupted: false, + logEntryCount: 10, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + const result = await saga.run(createInput()); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.sessionId).toBe("session-1"); + expect(result.data.snapshotApplied).toBe(true); + expect(result.data.conversationTurns).toBe(1); + }); + + it("closes cloud run before fetching logs", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + await saga.run(createInput()); + + expect(deps.closeCloudRun).toHaveBeenCalledWith( + "task-1", + "run-1", + "https://us.posthog.com", + 2, + ); + const closeOrder = (deps.closeCloudRun as ReturnType).mock + .invocationCallOrder[0]; + const fetchOrder = mockResumeFromLog.mock.invocationCallOrder[0]; + expect(closeOrder).toBeLessThan(fetchOrder); + }); + + it("skips snapshot apply when no archiveUrl", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: createSnapshot({ archiveUrl: undefined }), + snapshotApplied: false, + interrupted: false, + logEntryCount: 5, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + const result = await saga.run(createInput()); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.snapshotApplied).toBe(false); + expect(deps.applyTreeSnapshot).not.toHaveBeenCalled(); + }); + + it("skips snapshot apply when no snapshot at all", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + const result = await saga.run(createInput()); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.snapshotApplied).toBe(false); + expect(deps.applyTreeSnapshot).not.toHaveBeenCalled(); + }); + + it("seeds local logs when cloudLogUrl is present", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + await saga.run(createInput()); + + expect(deps.seedLocalLogs).toHaveBeenCalledWith( + "run-1", + "https://logs.example.com/run-1.ndjson", + ); + }); + + it("skips seeding logs when cloudLogUrl is falsy", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const apiClient = { + getTaskRun: vi.fn().mockResolvedValue({ log_url: undefined }), + }; + const deps = createDeps({ + createApiClient: vi.fn().mockReturnValue(apiClient), + }); + const saga = new HandoffSaga(deps); + await saga.run(createInput()); + + expect(deps.seedLocalLogs).not.toHaveBeenCalled(); + }); + + it("sets pending context with handoff summary", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 1, + }); + mockFormatConversation.mockReturnValue("User said hello"); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + await saga.run(createInput()); + + expect(deps.setPendingContext).toHaveBeenCalledWith( + "run-1", + expect.stringContaining("resuming a previous conversation"), + ); + expect(deps.setPendingContext).toHaveBeenCalledWith( + "run-1", + expect.stringContaining("could not be restored"), + ); + }); + + it("context mentions files restored when snapshot applied", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: createSnapshot(), + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + await saga.run(createInput()); + + expect(deps.setPendingContext).toHaveBeenCalledWith( + "run-1", + expect.stringContaining("fully restored"), + ); + }); + + it("passes sessionId and adapter through to reconnectSession", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + await saga.run(createInput({ sessionId: "ses-abc", adapter: "codex" })); + + expect(deps.reconnectSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "ses-abc", + adapter: "codex", + }), + ); + }); + + it("emits progress events in order", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: createSnapshot(), + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + await saga.run(createInput()); + + const progressCalls = (deps.onProgress as ReturnType).mock + .calls; + const steps = progressCalls.map((call: unknown[]) => call[0]); + expect(steps).toEqual([ + "fetching_logs", + "applying_snapshot", + "spawning_agent", + "complete", + ]); + }); + + describe("rollbacks", () => { + it("rolls back workspace mode when spawn_agent fails", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps({ + reconnectSession: vi.fn().mockRejectedValue(new Error("spawn failed")), + }); + const saga = new HandoffSaga(deps); + const result = await saga.run(createInput()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failedStep).toBe("spawn_agent"); + expect(deps.updateWorkspaceMode).toHaveBeenCalledWith("task-1", "cloud"); + }); + + it("kills session on rollback if spawn partially succeeded", async () => { + mockResumeFromLog.mockResolvedValue({ + conversation: [], + latestSnapshot: null, + snapshotApplied: false, + interrupted: false, + logEntryCount: 0, + }); + + const deps = createDeps({ + reconnectSession: vi.fn().mockResolvedValue(null), + }); + const saga = new HandoffSaga(deps); + const result = await saga.run(createInput()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failedStep).toBe("spawn_agent"); + }); + + it("fails at fetch_and_rebuild without rolling back workspace", async () => { + mockResumeFromLog.mockRejectedValue(new Error("API down")); + + const deps = createDeps(); + const saga = new HandoffSaga(deps); + const result = await saga.run(createInput()); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.failedStep).toBe("fetch_and_rebuild"); + expect(deps.updateWorkspaceMode).not.toHaveBeenCalled(); + expect(deps.reconnectSession).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/code/src/main/services/handoff/handoff-saga.ts b/apps/code/src/main/services/handoff/handoff-saga.ts new file mode 100644 index 000000000..b0bbba45b --- /dev/null +++ b/apps/code/src/main/services/handoff/handoff-saga.ts @@ -0,0 +1,202 @@ +import type { PostHogAPIClient } from "@posthog/agent/posthog-api"; +import { + type ConversationTurn, + formatConversationForResume, + resumeFromLog, +} from "@posthog/agent/resume"; +import type { TreeSnapshotEvent } from "@posthog/agent/types"; +import { Saga, type SagaLogger } from "@posthog/shared"; +import type { WorkspaceMode } from "../../db/repositories/workspace-repository"; +import type { SessionResponse } from "../agent/schemas"; +import type { HandoffStep } from "./schemas"; + +export interface HandoffSagaInput { + taskId: string; + runId: string; + repoPath: string; + apiHost: string; + teamId: number; + sessionId?: string; + adapter?: "claude" | "codex"; +} + +export interface HandoffSagaOutput { + sessionId: string; + snapshotApplied: boolean; + conversationTurns: number; +} + +export interface HandoffSagaDeps { + createApiClient(apiHost: string, teamId: number): PostHogAPIClient; + applyTreeSnapshot( + snapshot: TreeSnapshotEvent, + repoPath: string, + taskId: string, + runId: string, + apiClient: PostHogAPIClient, + ): Promise; + updateWorkspaceMode(taskId: string, mode: WorkspaceMode): void; + reconnectSession(params: { + taskId: string; + taskRunId: string; + repoPath: string; + apiHost: string; + projectId: number; + logUrl: string; + sessionId?: string; + adapter?: "claude" | "codex"; + }): Promise; + closeCloudRun( + taskId: string, + runId: string, + apiHost: string, + teamId: number, + ): Promise; + seedLocalLogs(runId: string, logUrl: string): Promise; + killSession(taskRunId: string): Promise; + setPendingContext(taskRunId: string, context: string): void; + onProgress(step: HandoffStep, message: string): void; +} + +export class HandoffSaga extends Saga { + readonly sagaName = "HandoffSaga"; + private deps: HandoffSagaDeps; + + constructor(deps: HandoffSagaDeps, logger?: SagaLogger) { + super(logger); + this.deps = deps; + } + + protected async execute(input: HandoffSagaInput): Promise { + const { taskId, runId, repoPath, apiHost, teamId } = input; + + this.deps.onProgress( + "fetching_logs", + "Closing cloud run and capturing snapshot...", + ); + + await this.readOnlyStep("close_cloud_run", async () => { + await this.deps.closeCloudRun(taskId, runId, apiHost, teamId); + }); + + const apiClient = this.deps.createApiClient(apiHost, teamId); + + const { resumeState, cloudLogUrl } = await this.readOnlyStep( + "fetch_and_rebuild", + async () => { + const taskRun = await apiClient.getTaskRun(taskId, runId); + const state = await resumeFromLog({ + taskId, + runId, + apiClient, + }); + return { resumeState: state, cloudLogUrl: taskRun.log_url }; + }, + ); + + let snapshotApplied = false; + const snapshot = resumeState.latestSnapshot; + if (snapshot?.archiveUrl) { + this.deps.onProgress( + "applying_snapshot", + "Applying cloud file state locally...", + ); + + await this.step({ + name: "apply_snapshot", + execute: async () => { + await this.deps.applyTreeSnapshot( + snapshot, + repoPath, + taskId, + runId, + apiClient, + ); + snapshotApplied = true; + }, + rollback: async () => {}, + }); + } + + await this.step({ + name: "update_workspace", + execute: async () => { + this.deps.updateWorkspaceMode(taskId, "local"); + }, + rollback: async () => { + this.deps.updateWorkspaceMode(taskId, "cloud"); + }, + }); + + if (cloudLogUrl) { + await this.step({ + name: "seed_local_logs", + execute: async () => { + await this.deps.seedLocalLogs(runId, cloudLogUrl); + }, + rollback: async () => {}, + }); + } + + this.deps.onProgress("spawning_agent", "Starting local agent..."); + + let agentSessionId = ""; + await this.step({ + name: "spawn_agent", + execute: async () => { + const response = await this.deps.reconnectSession({ + taskId, + taskRunId: runId, + repoPath, + apiHost, + projectId: teamId, + logUrl: cloudLogUrl, + sessionId: input.sessionId, + adapter: input.adapter, + }); + if (!response) { + throw new Error("Failed to create local agent session"); + } + agentSessionId = response.sessionId; + }, + rollback: async () => { + await this.deps.killSession(runId).catch(() => {}); + }, + }); + + await this.readOnlyStep("set_context", async () => { + const context = this.buildHandoffContext( + resumeState.conversation, + snapshotApplied, + ); + this.deps.setPendingContext(runId, context); + }); + + this.deps.onProgress("complete", "Handoff complete"); + + return { + sessionId: agentSessionId, + snapshotApplied, + conversationTurns: resumeState.conversation.length, + }; + } + + private buildHandoffContext( + conversation: ConversationTurn[], + snapshotApplied: boolean, + ): string { + const conversationSummary = formatConversationForResume(conversation); + + const fileStatus = snapshotApplied + ? "The workspace files have been fully restored from the cloud session." + : "The workspace files from the cloud session could not be restored. You are working with the local file state."; + + return ( + `You are resuming a previous conversation that was running in a cloud sandbox. ` + + `The user has transferred you to their local machine. ${fileStatus}\n\n` + + `Here is the conversation history from the cloud session:\n\n` + + `${conversationSummary}\n\n` + + `The user will now send you a message. Respond to it with full context from the session above.` + ); + } +} diff --git a/apps/code/src/main/services/handoff/schemas.ts b/apps/code/src/main/services/handoff/schemas.ts new file mode 100644 index 000000000..3cf46cf56 --- /dev/null +++ b/apps/code/src/main/services/handoff/schemas.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +export const handoffPreflightInput = z.object({ + taskId: z.string(), + runId: z.string(), + repoPath: z.string(), + apiHost: z.string(), + teamId: z.number(), +}); + +export type HandoffPreflightInput = z.infer; + +export const handoffPreflightResult = z.object({ + canHandoff: z.boolean(), + reason: z.string().optional(), + localTreeDirty: z.boolean(), +}); + +export type HandoffPreflightResult = z.infer; + +export const handoffExecuteInput = z.object({ + taskId: z.string(), + runId: z.string(), + repoPath: z.string(), + apiHost: z.string(), + teamId: z.number(), + sessionId: z.string().optional(), + adapter: z.enum(["claude", "codex"]).optional(), +}); + +export type HandoffExecuteInput = z.infer; + +export const handoffExecuteResult = z.object({ + success: z.boolean(), + sessionId: z.string().optional(), + error: z.string().optional(), +}); + +export type HandoffExecuteResult = z.infer; + +export type HandoffStep = + | "fetching_logs" + | "applying_snapshot" + | "updating_run" + | "spawning_agent" + | "complete" + | "failed"; + +export interface HandoffProgressPayload { + taskId: string; + step: HandoffStep; + message: string; +} + +export const HandoffEvent = { + Progress: "handoff-progress", +} as const; + +export interface HandoffServiceEvents { + [HandoffEvent.Progress]: HandoffProgressPayload; +} diff --git a/apps/code/src/main/services/handoff/service.test.ts b/apps/code/src/main/services/handoff/service.test.ts new file mode 100644 index 000000000..c529af4e2 --- /dev/null +++ b/apps/code/src/main/services/handoff/service.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetChangedFilesHead = vi.hoisted(() => vi.fn()); +const mockReconnectSession = vi.hoisted(() => vi.fn()); +const mockCancelSession = vi.hoisted(() => vi.fn()); +const mockSetPendingContext = vi.hoisted(() => vi.fn()); +const mockSendCommand = vi.hoisted(() => vi.fn()); +const mockCreatePosthogConfig = vi.hoisted(() => vi.fn()); +const mockUpdateMode = vi.hoisted(() => vi.fn()); +const mockNetFetch = vi.hoisted(() => vi.fn()); + +vi.mock("@main/utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +vi.mock("@main/utils/typed-event-emitter", () => ({ + TypedEventEmitter: class { + emit = vi.fn(); + }, +})); + +vi.mock("inversify", () => ({ + injectable: () => (target: unknown) => target, + inject: () => () => undefined, +})); + +vi.mock("electron", () => ({ + app: { getPath: () => "/home" }, + net: { fetch: mockNetFetch }, +})); + +vi.mock("@posthog/agent/posthog-api", () => ({ + PostHogAPIClient: vi.fn(), +})); + +vi.mock("@posthog/agent/tree-tracker", () => ({ + TreeTracker: vi.fn().mockImplementation(() => ({ + applyTreeSnapshot: vi.fn(), + })), +})); + +vi.mock("@main/di/tokens", () => ({ + MAIN_TOKENS: { + GitService: Symbol("GitService"), + AgentService: Symbol("AgentService"), + CloudTaskService: Symbol("CloudTaskService"), + AgentAuthAdapter: Symbol("AgentAuthAdapter"), + WorkspaceRepository: Symbol("WorkspaceRepository"), + }, +})); + +import type { HandoffPreflightInput } from "./schemas"; +import { HandoffService } from "./service"; + +function createService(): HandoffService { + const gitService = { getChangedFilesHead: mockGetChangedFilesHead } as never; + const agentService = { + reconnectSession: mockReconnectSession, + cancelSession: mockCancelSession, + setPendingContext: mockSetPendingContext, + } as never; + const cloudTaskService = { sendCommand: mockSendCommand } as never; + const agentAuthAdapter = { + createPosthogConfig: mockCreatePosthogConfig, + } as never; + const workspaceRepo = { updateMode: mockUpdateMode } as never; + + return new HandoffService( + gitService, + agentService, + cloudTaskService, + agentAuthAdapter, + workspaceRepo, + ); +} + +function createPreflightInput( + overrides: Partial = {}, +): HandoffPreflightInput { + return { + taskId: "task-1", + runId: "run-1", + repoPath: "/repo/path", + apiHost: "https://us.posthog.com", + teamId: 2, + ...overrides, + }; +} + +describe("HandoffService.preflight", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns canHandoff=true when working tree is clean", async () => { + mockGetChangedFilesHead.mockResolvedValue([]); + + const service = createService(); + const result = await service.preflight(createPreflightInput()); + + expect(result.canHandoff).toBe(true); + expect(result.localTreeDirty).toBe(false); + expect(result.reason).toBeUndefined(); + }); + + it("returns canHandoff=false when working tree has changes", async () => { + mockGetChangedFilesHead.mockResolvedValue([ + { path: "src/index.ts", status: "M" }, + ]); + + const service = createService(); + const result = await service.preflight(createPreflightInput()); + + expect(result.canHandoff).toBe(false); + expect(result.localTreeDirty).toBe(true); + expect(result.reason).toContain("uncommitted changes"); + }); + + it("checks the correct repo path", async () => { + mockGetChangedFilesHead.mockResolvedValue([]); + + const service = createService(); + await service.preflight(createPreflightInput({ repoPath: "/custom/path" })); + + expect(mockGetChangedFilesHead).toHaveBeenCalledWith("/custom/path"); + }); + + it("returns canHandoff=true when git check throws", async () => { + mockGetChangedFilesHead.mockRejectedValue(new Error("git not found")); + + const service = createService(); + const result = await service.preflight(createPreflightInput()); + + expect(result.canHandoff).toBe(true); + expect(result.localTreeDirty).toBe(false); + }); +}); diff --git a/apps/code/src/main/services/handoff/service.ts b/apps/code/src/main/services/handoff/service.ts new file mode 100644 index 000000000..3dae58090 --- /dev/null +++ b/apps/code/src/main/services/handoff/service.ts @@ -0,0 +1,179 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { MAIN_TOKENS } from "@main/di/tokens"; +import { logger } from "@main/utils/logger"; +import { TypedEventEmitter } from "@main/utils/typed-event-emitter"; +import { PostHogAPIClient } from "@posthog/agent/posthog-api"; +import { TreeTracker } from "@posthog/agent/tree-tracker"; +import type { TreeSnapshotEvent } from "@posthog/agent/types"; +import { app, net } from "electron"; +import { inject, injectable } from "inversify"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import type { AgentAuthAdapter } from "../agent/auth-adapter"; +import type { AgentService } from "../agent/service"; +import type { CloudTaskService } from "../cloud-task/service"; +import type { GitService } from "../git/service"; +import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; +import { + HandoffEvent, + type HandoffExecuteInput, + type HandoffExecuteResult, + type HandoffPreflightInput, + type HandoffPreflightResult, + type HandoffServiceEvents, +} from "./schemas"; + +const log = logger.scope("handoff"); + +@injectable() +export class HandoffService extends TypedEventEmitter { + constructor( + @inject(MAIN_TOKENS.GitService) private readonly gitService: GitService, + @inject(MAIN_TOKENS.AgentService) + private readonly agentService: AgentService, + @inject(MAIN_TOKENS.CloudTaskService) + private readonly cloudTaskService: CloudTaskService, + @inject(MAIN_TOKENS.AgentAuthAdapter) + private readonly agentAuthAdapter: AgentAuthAdapter, + @inject(MAIN_TOKENS.WorkspaceRepository) + private readonly workspaceRepo: IWorkspaceRepository, + ) { + super(); + } + + async preflight( + input: HandoffPreflightInput, + ): Promise { + const { repoPath } = input; + + let localTreeDirty = false; + try { + const changedFiles = await this.gitService.getChangedFilesHead(repoPath); + localTreeDirty = changedFiles.length > 0; + } catch (err) { + log.warn("Failed to check local working tree", { repoPath, err }); + } + + const canHandoff = !localTreeDirty; + const reason = localTreeDirty + ? "Local working tree has uncommitted changes. Commit or stash them first." + : undefined; + + return { canHandoff, reason, localTreeDirty }; + } + + async execute(input: HandoffExecuteInput): Promise { + const deps: HandoffSagaDeps = { + createApiClient: (apiHost: string, teamId: number) => { + const config = this.agentAuthAdapter.createPosthogConfig({ + apiHost, + projectId: teamId, + }); + return new PostHogAPIClient(config); + }, + + applyTreeSnapshot: async ( + snapshot: TreeSnapshotEvent, + repoPath: string, + taskId: string, + runId: string, + apiClient: PostHogAPIClient, + ) => { + const tracker = new TreeTracker({ + repositoryPath: repoPath, + taskId, + runId, + apiClient, + }); + await tracker.applyTreeSnapshot({ + ...snapshot, + baseCommit: null, + }); + }, + + closeCloudRun: async (taskId, runId, apiHost, teamId) => { + const result = await this.cloudTaskService.sendCommand({ + taskId, + runId, + apiHost, + teamId, + method: "close", + }); + if (!result.success) { + log.warn("Close command failed, continuing with handoff", { + error: result.error, + }); + } + }, + + updateWorkspaceMode: (taskId, mode) => { + this.workspaceRepo.updateMode(taskId, mode); + }, + + seedLocalLogs: async (runId: string, logUrl: string) => { + const response = await net.fetch(logUrl); + if (!response.ok) { + log.warn("Failed to fetch cloud logs for seeding", { + status: response.status, + }); + return; + } + const content = await response.text(); + if (!content?.trim()) return; + + const logDir = join( + app.getPath("home"), + ".posthog-code", + "sessions", + runId, + ); + mkdirSync(logDir, { recursive: true }); + writeFileSync(join(logDir, "logs.ndjson"), content); + log.info("Seeded local logs from cloud", { + runId, + bytes: content.length, + }); + }, + + reconnectSession: async (params) => { + return this.agentService.reconnectSession(params); + }, + + killSession: async (taskRunId: string) => { + await this.agentService.cancelSession(taskRunId); + }, + + setPendingContext: (taskRunId: string, context: string) => { + this.agentService.setPendingContext(taskRunId, context); + }, + + onProgress: (step, message) => { + this.emit(HandoffEvent.Progress, { + taskId: input.taskId, + step, + message, + }); + }, + }; + + const saga = new HandoffSaga(deps, log); + const result = await saga.run(input); + + if (!result.success) { + log.error("Handoff saga failed", { + error: result.error, + failedStep: result.failedStep, + }); + deps.onProgress("failed", result.error ?? "Handoff failed"); + return { + success: false, + error: `Handoff failed at step '${result.failedStep}': ${result.error}`, + }; + } + + return { + success: true, + sessionId: result.data.sessionId, + }; + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75ae41d5f..0d4c2a8dd 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -15,6 +15,7 @@ import { foldersRouter } from "./routers/folders"; import { fsRouter } from "./routers/fs"; import { gitRouter } from "./routers/git"; import { githubIntegrationRouter } from "./routers/github-integration"; +import { handoffRouter } from "./routers/handoff"; import { linearIntegrationRouter } from "./routers/linear-integration.js"; import { llmGatewayRouter } from "./routers/llm-gateway"; import { logsRouter } from "./routers/logs"; @@ -53,6 +54,7 @@ export const trpcRouter = router({ fs: fsRouter, git: gitRouter, githubIntegration: githubIntegrationRouter, + handoff: handoffRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, mcpApps: mcpAppsRouter, diff --git a/apps/code/src/main/trpc/routers/handoff.ts b/apps/code/src/main/trpc/routers/handoff.ts new file mode 100644 index 000000000..2b3ef2c2a --- /dev/null +++ b/apps/code/src/main/trpc/routers/handoff.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + HandoffEvent, + handoffExecuteInput, + handoffExecuteResult, + handoffPreflightInput, + handoffPreflightResult, +} from "../../services/handoff/schemas"; +import type { HandoffService } from "../../services/handoff/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.HandoffService); + +export const handoffRouter = router({ + preflight: publicProcedure + .input(handoffPreflightInput) + .output(handoffPreflightResult) + .query(({ input }) => getService().preflight(input)), + + execute: publicProcedure + .input(handoffExecuteInput) + .output(handoffExecuteResult) + .mutation(({ input }) => getService().execute(input)), + + onProgress: publicProcedure + .input(z.object({ taskId: z.string() })) + .subscription(async function* (opts) { + const service = getService(); + for await (const data of service.toIterable(HandoffEvent.Progress, { + signal: opts.signal, + })) { + if (data.taskId === opts.input.taskId) { + yield data; + } + } + }), +}); diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index 90df2ee26..eb259e819 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -127,7 +127,7 @@ export function HeaderRow() { {rightSidebarOpen && (isCloudTask ? ( - + ) : ( ))} diff --git a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx index ce395912d..588e5ecc1 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -1,29 +1,49 @@ +import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks"; import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { Eye } from "@phosphor-icons/react"; import { Button, Flex, Text } from "@radix-ui/themes"; +import type { Task } from "@shared/types"; interface CloudGitInteractionHeaderProps { taskId: string; + task: Task; } export function CloudGitInteractionHeader({ taskId, + task, }: CloudGitInteractionHeaderProps) { const session = useSessionForTask(taskId); const prUrl = (session?.cloudOutput?.pr_url as string) ?? null; - - if (!prUrl) return null; + const { handleContinueLocally } = useSessionCallbacks({ + taskId, + task, + session: session ?? undefined, + repoPath: null, + }); return ( -
- -
+ {prUrl && ( + + )} + ); } diff --git a/apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx b/apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx index f556ed232..00145e155 100644 --- a/apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx +++ b/apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx @@ -1,6 +1,6 @@ import { Cloud as CloudIcon } from "@phosphor-icons/react"; import { Flex, Text } from "@radix-ui/themes"; -import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; +import { useIsCloudTask } from "@renderer/features/workspace/hooks/useIsCloudTask"; import type { Task } from "@shared/types"; import type React from "react"; import { useMemo } from "react"; @@ -48,8 +48,7 @@ export const LeafNodeRenderer: React.FC = ({ closeTab, ); - const workspace = useWorkspace(taskId); - const isCloud = workspace?.mode === "cloud"; + const isCloud = useIsCloudTask(taskId); const cloudEmptyState = useMemo( () => diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts index ab82416ef..426bee9d0 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts @@ -13,6 +13,42 @@ import { sessionStoreSetters } from "../stores/sessionStore"; const log = logger.scope("session-callbacks"); +async function resolveRepoPathFromRemote( + remoteUrl: string | undefined | null, +): Promise { + if (!remoteUrl) return null; + const repo = await trpcClient.folders.getRepositoryByRemoteUrl.query({ + remoteUrl, + }); + return repo?.path ?? null; +} + +async function resolveRepoPathFromPicker( + taskId: string, +): Promise { + const selectedPath = await trpcClient.os.selectDirectory.query(); + if (!selectedPath) return null; + + let folder = (await trpcClient.folders.getFolders.query()).find( + (f) => f.path === selectedPath, + ); + if (!folder) { + folder = await trpcClient.folders.addFolder.mutate({ + folderPath: selectedPath, + }); + } + + await trpcClient.workspace.create.mutate({ + taskId, + mainRepoPath: selectedPath, + folderId: folder.id, + folderPath: selectedPath, + mode: "local", + }); + + return selectedPath; +} + interface UseSessionCallbacksOptions { taskId: string; task: Task; @@ -147,11 +183,28 @@ export function useSessionCallbacks({ [taskId, repoPath], ); + const handleContinueLocally = useCallback(async () => { + try { + const targetPath = + (await resolveRepoPathFromRemote(task.repository)) ?? + (await resolveRepoPathFromPicker(taskId)); + + if (!targetPath) return; + + await getSessionService().handoffToLocal(taskId, targetPath); + } catch (error) { + log.error("Failed to hand off to local", error); + const message = error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to continue locally: ${message}`); + } + }, [taskId, task.repository]); + return { handleSendPrompt, handleCancelPrompt, handleRetry, handleNewSession, handleBashCommand, + handleContinueLocally, }; } diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts index 1cd5eee13..d0052d7d9 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts @@ -1,4 +1,5 @@ import { useCwd } from "@features/sidebar/hooks/useCwd"; +import { useIsCloudTask } from "@features/workspace/hooks/useIsCloudTask"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import type { Task } from "@shared/types"; import { useSessionForTask } from "../stores/sessionStore"; @@ -7,9 +8,7 @@ export function useSessionViewState(taskId: string, task: Task) { const session = useSessionForTask(taskId); const repoPath = useCwd(taskId) ?? null; const workspace = useWorkspace(taskId); - - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; + const isCloud = useIsCloudTask(taskId); const cloudStatus = session?.cloudStatus ?? null; const isCloudRunNotTerminal = diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4d97a7868..0f644b0f9 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -28,6 +28,7 @@ import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { taskViewedApi } from "@features/sidebar/hooks/useTaskViewed"; import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { getIsOnline } from "@renderer/stores/connectivityStore"; +import { trpc } from "@renderer/trpc"; import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; @@ -1890,6 +1891,116 @@ export class SessionService { ); } + async handoffToLocal(taskId: string, repoPath: string): Promise { + const session = sessionStoreSetters.getSessionByTaskId(taskId); + if (!session) { + log.warn("No session found for handoff", { taskId }); + return; + } + + const runId = session.taskRunId; + const auth = await this.getHandoffAuth(); + if (!auth) return; + + sessionStoreSetters.updateSession(runId, { handoffInProgress: true }); + + try { + await this.runHandoffPreflight(taskId, runId, repoPath, auth); + this.stopCloudTaskWatch(taskId); + sessionStoreSetters.updateSession(runId, { status: "connecting" }); + await this.executeHandoff(taskId, runId, repoPath, auth); + this.transitionToLocalSession(runId); + this.subscribeToChannel(runId); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()); + log.info("Cloud-to-local handoff complete", { taskId, runId }); + } catch (err) { + log.error("Handoff failed", { taskId, err }); + toast.error( + err instanceof Error ? err.message : "Handoff to local failed", + ); + this.watchCloudTask(taskId, runId); + sessionStoreSetters.updateSession(runId, { + handoffInProgress: false, + status: "disconnected", + }); + } + } + + private async getHandoffAuth(): Promise<{ + apiHost: string; + projectId: number; + } | null> { + let auth: Awaited>; + try { + auth = await fetchAuthState(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + toast.error(`Authentication required for handoff: ${message}`); + return null; + } + if (!auth.projectId || !auth.cloudRegion) { + toast.error("Missing project configuration for handoff"); + return null; + } + return { + apiHost: getCloudUrlFromRegion(auth.cloudRegion), + projectId: auth.projectId, + }; + } + + private async runHandoffPreflight( + taskId: string, + runId: string, + repoPath: string, + auth: { apiHost: string; projectId: number }, + ): Promise { + const preflight = await trpcClient.handoff.preflight.query({ + taskId, + runId, + repoPath, + apiHost: auth.apiHost, + teamId: auth.projectId, + }); + if (!preflight.canHandoff) { + sessionStoreSetters.updateSession(runId, { + handoffInProgress: false, + }); + throw new Error(preflight.reason ?? "Cannot hand off to local"); + } + } + + private async executeHandoff( + taskId: string, + runId: string, + repoPath: string, + auth: { apiHost: string; projectId: number }, + ): Promise { + const result = await trpcClient.handoff.execute.mutate({ + taskId, + runId, + repoPath, + apiHost: auth.apiHost, + teamId: auth.projectId, + }); + if (!result.success) { + throw new Error(result.error ?? "Handoff failed"); + } + } + + private transitionToLocalSession(runId: string): void { + sessionStoreSetters.updateSession(runId, { + isCloud: false, + cloudStatus: undefined, + cloudStage: undefined, + cloudOutput: undefined, + cloudErrorMessage: undefined, + cloudBranch: undefined, + handoffInProgress: false, + status: "connected", + }); + } + public updateSessionTaskTitle(taskId: string, taskTitle: string): void { const session = sessionStoreSetters.getSessionByTaskId(taskId); if (!session) return; diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index cd3fa84ee..5365dce41 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -73,6 +73,8 @@ export interface AgentSession { initialPrompt?: ContentBlock[]; /** Cloud task branch */ cloudBranch?: string | null; + /** Whether a cloud-to-local handoff is in progress */ + handoffInProgress?: boolean; /** Number of session/prompt events to skip from polled logs (set during resume) */ skipPolledPromptCount?: number; optimisticItems: OptimisticItem[]; diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx index 990b6e9cd..ab517f89b 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx @@ -30,6 +30,7 @@ import { } from "@radix-ui/themes"; import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; import { getStatusIndicator } from "@renderer/features/git-interaction/utils/gitStatusUtils"; +import { useIsCloudTask } from "@renderer/features/workspace/hooks/useIsCloudTask"; import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; import { trpcClient } from "@renderer/trpc/client"; import { track } from "@renderer/utils/analytics"; @@ -466,9 +467,7 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) { } export function ChangesPanel({ taskId, task }: ChangesPanelProps) { - const workspace = useWorkspace(taskId); - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; + const isCloud = useIsCloudTask(taskId); if (isCloud) { return ; diff --git a/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx b/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx index a5e3d64da..2534bc7c3 100644 --- a/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx @@ -10,6 +10,7 @@ import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState"; import { Cloud } from "@phosphor-icons/react"; import { Box, Button, Flex, Spinner, Text } from "@radix-ui/themes"; +import { useIsCloudTask } from "@renderer/features/workspace/hooks/useIsCloudTask"; import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; @@ -194,9 +195,7 @@ function CloudFileTreePanel({ taskId, task }: FileTreePanelProps) { } export function FileTreePanel({ taskId, task }: FileTreePanelProps) { - const workspace = useWorkspace(taskId); - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; + const isCloud = useIsCloudTask(taskId); if (isCloud) { return ; diff --git a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx b/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx index 32ad9e0d9..fe2a192b5 100644 --- a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx @@ -5,7 +5,7 @@ import { ChangesPanel } from "@features/task-detail/components/ChangesPanel"; import { FileTreePanel } from "@features/task-detail/components/FileTreePanel"; import { TaskLogsPanel } from "@features/task-detail/components/TaskLogsPanel"; import { TaskShellPanel } from "@features/task-detail/components/TaskShellPanel"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { useIsCloudTask } from "@features/workspace/hooks/useIsCloudTask"; import { CloudReviewPage } from "@renderer/features/code-review/components/CloudReviewPage"; import { ReviewPage } from "@renderer/features/code-review/components/ReviewPage"; import type { Task } from "@shared/types"; @@ -21,7 +21,7 @@ export function TabContentRenderer({ taskId, task, }: TabContentRendererProps) { - const workspace = useWorkspace(taskId); + const isCloud = useIsCloudTask(taskId); const { data } = tab; switch (data.type) { @@ -43,8 +43,6 @@ export function TabContentRenderer({ ); case "review": { - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; return isCloud ? ( ) : ( diff --git a/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts b/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts new file mode 100644 index 000000000..438bf2d45 --- /dev/null +++ b/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts @@ -0,0 +1,6 @@ +import { useWorkspace } from "./useWorkspace"; + +export function useIsCloudTask(taskId: string): boolean { + const workspace = useWorkspace(taskId); + return workspace?.mode === "cloud"; +} diff --git a/packages/agent/package.json b/packages/agent/package.json index 07ef18fbf..28fd0e5d4 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -52,6 +52,14 @@ "types": "./dist/execution-mode.d.ts", "import": "./dist/execution-mode.js" }, + "./resume": { + "types": "./dist/resume.d.ts", + "import": "./dist/resume.js" + }, + "./tree-tracker": { + "types": "./dist/tree-tracker.d.ts", + "import": "./dist/tree-tracker.js" + }, "./server": { "types": "./dist/server/agent-server.d.ts", "import": "./dist/server/agent-server.js" diff --git a/packages/agent/src/resume.ts b/packages/agent/src/resume.ts index 628a25c87..cd7f8cd0f 100644 --- a/packages/agent/src/resume.ts +++ b/packages/agent/src/resume.ts @@ -16,6 +16,7 @@ */ import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { selectRecentTurns } from "./adapters/claude/session/jsonl-hydration"; import type { PostHogAPIClient } from "./posthog-api"; import { ResumeSaga } from "./sagas/resume-saga"; import type { DeviceInfo, TreeSnapshotEvent } from "./types"; @@ -113,3 +114,53 @@ export function conversationToPromptHistory( content: turn.content, })); } + +const RESUME_HISTORY_TOKEN_BUDGET = 50_000; +const TOOL_RESULT_MAX_CHARS = 2000; + +export function formatConversationForResume( + conversation: ConversationTurn[], +): string { + const selected = selectRecentTurns(conversation, RESUME_HISTORY_TOKEN_BUDGET); + const parts: string[] = []; + + if (selected.length < conversation.length) { + parts.push( + `*(${conversation.length - selected.length} earlier turns omitted)*`, + ); + } + + for (const turn of selected) { + const role = turn.role === "user" ? "User" : "Assistant"; + + const textParts = turn.content + .filter((block) => block.type === "text") + .map((block) => (block as { type: "text"; text: string }).text); + + if (textParts.length > 0) { + parts.push(`**${role}**: ${textParts.join("\n")}`); + } + + if (turn.toolCalls?.length) { + const toolSummary = turn.toolCalls + .map((tc) => { + let resultStr = ""; + if (tc.result !== undefined) { + const raw = + typeof tc.result === "string" + ? tc.result + : JSON.stringify(tc.result); + resultStr = + raw.length > TOOL_RESULT_MAX_CHARS + ? ` → ${raw.substring(0, TOOL_RESULT_MAX_CHARS)}...(truncated)` + : ` → ${raw}`; + } + return ` - ${tc.toolName}${resultStr}`; + }) + .join("\n"); + parts.push(`**${role} (tools)**:\n${toolSummary}`); + } + } + + return parts.join("\n\n"); +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 8f8fa25fe..075b43ec9 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -11,10 +11,9 @@ import { createAcpConnection, type InProcessAcpConnection, } from "../adapters/acp-connection"; -import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration"; import { PostHogAPIClient } from "../posthog-api"; import { - type ConversationTurn, + formatConversationForResume, type ResumeState, resumeFromLog, } from "../resume"; @@ -882,7 +881,7 @@ export class AgentServer { if (!this.session || !this.resumeState) return; try { - const conversationSummary = this.formatConversationForResume( + const conversationSummary = formatConversationForResume( this.resumeState.conversation, ); @@ -949,59 +948,6 @@ export class AgentServer { } } - private static RESUME_HISTORY_TOKEN_BUDGET = 50_000; - private static TOOL_RESULT_MAX_CHARS = 2000; - - private formatConversationForResume( - conversation: ConversationTurn[], - ): string { - const selected = selectRecentTurns( - conversation, - AgentServer.RESUME_HISTORY_TOKEN_BUDGET, - ); - const parts: string[] = []; - - if (selected.length < conversation.length) { - parts.push( - `*(${conversation.length - selected.length} earlier turns omitted)*`, - ); - } - - for (const turn of selected) { - const role = turn.role === "user" ? "User" : "Assistant"; - - const textParts = turn.content - .filter((block) => block.type === "text") - .map((block) => (block as { type: "text"; text: string }).text); - - if (textParts.length > 0) { - parts.push(`**${role}**: ${textParts.join("\n")}`); - } - - if (turn.toolCalls?.length) { - const toolSummary = turn.toolCalls - .map((tc) => { - let resultStr = ""; - if (tc.result !== undefined) { - const raw = - typeof tc.result === "string" - ? tc.result - : JSON.stringify(tc.result); - resultStr = - raw.length > AgentServer.TOOL_RESULT_MAX_CHARS - ? ` → ${raw.substring(0, AgentServer.TOOL_RESULT_MAX_CHARS)}...(truncated)` - : ` → ${raw}`; - } - return ` - ${tc.toolName}${resultStr}`; - }) - .join("\n"); - parts.push(`**${role} (tools)**:\n${toolSummary}`); - } - } - - return parts.join("\n\n"); - } - private getInitialPromptOverride(taskRun: TaskRun): string | null { const state = taskRun.state as Record | undefined; const override = state?.initial_prompt_override; diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 9af0bc8ff..eb80ccad2 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -75,6 +75,8 @@ export default defineConfig([ "src/agent.ts", "src/gateway-models.ts", "src/posthog-api.ts", + "src/resume.ts", + "src/tree-tracker.ts", "src/types.ts", "src/adapters/claude/questions/utils.ts", "src/adapters/claude/permissions/permission-options.ts",