Skip to content

Commit 4e458fa

Browse files
committed
feat: cloud to local handoff
1 parent 6a13d32 commit 4e458fa

File tree

27 files changed

+1335
-121
lines changed

27 files changed

+1335
-121
lines changed

apps/code/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { FoldersService } from "../services/folders/service";
2828
import { FsService } from "../services/fs/service";
2929
import { GitService } from "../services/git/service";
3030
import { GitHubIntegrationService } from "../services/github-integration/service";
31+
import { HandoffService } from "../services/handoff/service";
3132
import { LinearIntegrationService } from "../services/linear-integration/service";
3233
import { LlmGatewayService } from "../services/llm-gateway/service";
3334
import { McpAppsService } from "../services/mcp-apps/service";
@@ -88,6 +89,7 @@ container
8889
.bind(MAIN_TOKENS.GitHubIntegrationService)
8990
.to(GitHubIntegrationService);
9091
container.bind(MAIN_TOKENS.GitService).to(GitService);
92+
container.bind(MAIN_TOKENS.HandoffService).to(HandoffService);
9193
container
9294
.bind(MAIN_TOKENS.LinearIntegrationService)
9395
.to(LinearIntegrationService);

apps/code/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const MAIN_TOKENS = Object.freeze({
3939
FoldersService: Symbol.for("Main.FoldersService"),
4040
FsService: Symbol.for("Main.FsService"),
4141
GitService: Symbol.for("Main.GitService"),
42+
HandoffService: Symbol.for("Main.HandoffService"),
4243
GitHubIntegrationService: Symbol.for("Main.GitHubIntegrationService"),
4344
LinearIntegrationService: Symbol.for("Main.LinearIntegrationService"),
4445
DeepLinkService: Symbol.for("Main.DeepLinkService"),

apps/code/src/main/services/agent/service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,19 @@ When creating pull requests, add the following footer at the end of the PR descr
10381038
]);
10391039
}
10401040

1041+
setPendingContext(taskRunId: string, context: string): void {
1042+
const session = this.sessions.get(taskRunId);
1043+
if (!session) {
1044+
log.warn("Session not found for setPendingContext", { taskRunId });
1045+
return;
1046+
}
1047+
session.pendingContext = context;
1048+
log.info("Set pending context on session", {
1049+
taskRunId,
1050+
contextLength: context.length,
1051+
});
1052+
}
1053+
10411054
/**
10421055
* Notify a session of a context change (CWD moved, detached HEAD, etc).
10431056
* Used when focusing/unfocusing worktrees - the agent doesn't need to respawn
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import type { TreeSnapshotEvent } from "@posthog/agent/types";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { HandoffSagaDeps, HandoffSagaInput } from "./handoff-saga";
4+
import { HandoffSaga } from "./handoff-saga";
5+
6+
const mockResumeFromLog = vi.hoisted(() => vi.fn());
7+
const mockFormatConversation = vi.hoisted(() => vi.fn());
8+
9+
vi.mock("@posthog/agent/resume", () => ({
10+
resumeFromLog: mockResumeFromLog,
11+
formatConversationForResume: mockFormatConversation,
12+
}));
13+
14+
function createInput(
15+
overrides: Partial<HandoffSagaInput> = {},
16+
): HandoffSagaInput {
17+
return {
18+
taskId: "task-1",
19+
runId: "run-1",
20+
repoPath: "/repo",
21+
apiHost: "https://us.posthog.com",
22+
teamId: 2,
23+
...overrides,
24+
};
25+
}
26+
27+
function createSnapshot(
28+
overrides: Partial<TreeSnapshotEvent> = {},
29+
): TreeSnapshotEvent {
30+
return {
31+
treeHash: "abc123",
32+
baseCommit: "def456",
33+
archiveUrl: "https://s3.example.com/archive.tar.gz",
34+
changes: [{ path: "test.txt", status: "A" }],
35+
timestamp: "2026-04-07T00:00:00Z",
36+
...overrides,
37+
};
38+
}
39+
40+
function createDeps(overrides: Partial<HandoffSagaDeps> = {}): HandoffSagaDeps {
41+
return {
42+
createApiClient: vi.fn().mockReturnValue({
43+
getTaskRun: vi.fn().mockResolvedValue({
44+
log_url: "https://logs.example.com/run-1.ndjson",
45+
}),
46+
}),
47+
applyTreeSnapshot: vi.fn().mockResolvedValue(undefined),
48+
updateWorkspaceMode: vi.fn(),
49+
reconnectSession: vi.fn().mockResolvedValue({
50+
sessionId: "session-1",
51+
channel: "ch-1",
52+
}),
53+
closeCloudRun: vi.fn().mockResolvedValue(undefined),
54+
seedLocalLogs: vi.fn().mockResolvedValue(undefined),
55+
killSession: vi.fn().mockResolvedValue(undefined),
56+
setPendingContext: vi.fn(),
57+
onProgress: vi.fn(),
58+
...overrides,
59+
};
60+
}
61+
62+
describe("HandoffSaga", () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks();
65+
mockFormatConversation.mockReturnValue("conversation summary");
66+
});
67+
68+
it("completes happy path with snapshot", async () => {
69+
const snapshot = createSnapshot();
70+
mockResumeFromLog.mockResolvedValue({
71+
conversation: [
72+
{ role: "user", content: [{ type: "text", text: "hello" }] },
73+
],
74+
latestSnapshot: snapshot,
75+
snapshotApplied: false,
76+
interrupted: false,
77+
logEntryCount: 10,
78+
});
79+
80+
const deps = createDeps();
81+
const saga = new HandoffSaga(deps);
82+
const result = await saga.run(createInput());
83+
84+
expect(result.success).toBe(true);
85+
if (!result.success) return;
86+
expect(result.data.sessionId).toBe("session-1");
87+
expect(result.data.snapshotApplied).toBe(true);
88+
expect(result.data.conversationTurns).toBe(1);
89+
});
90+
91+
it("closes cloud run before fetching logs", async () => {
92+
mockResumeFromLog.mockResolvedValue({
93+
conversation: [],
94+
latestSnapshot: null,
95+
snapshotApplied: false,
96+
interrupted: false,
97+
logEntryCount: 0,
98+
});
99+
100+
const deps = createDeps();
101+
const saga = new HandoffSaga(deps);
102+
await saga.run(createInput());
103+
104+
expect(deps.closeCloudRun).toHaveBeenCalledWith(
105+
"task-1",
106+
"run-1",
107+
"https://us.posthog.com",
108+
2,
109+
);
110+
const closeOrder = (deps.closeCloudRun as ReturnType<typeof vi.fn>).mock
111+
.invocationCallOrder[0];
112+
const fetchOrder = mockResumeFromLog.mock.invocationCallOrder[0];
113+
expect(closeOrder).toBeLessThan(fetchOrder);
114+
});
115+
116+
it("skips snapshot apply when no archiveUrl", async () => {
117+
mockResumeFromLog.mockResolvedValue({
118+
conversation: [],
119+
latestSnapshot: createSnapshot({ archiveUrl: undefined }),
120+
snapshotApplied: false,
121+
interrupted: false,
122+
logEntryCount: 5,
123+
});
124+
125+
const deps = createDeps();
126+
const saga = new HandoffSaga(deps);
127+
const result = await saga.run(createInput());
128+
129+
expect(result.success).toBe(true);
130+
if (!result.success) return;
131+
expect(result.data.snapshotApplied).toBe(false);
132+
expect(deps.applyTreeSnapshot).not.toHaveBeenCalled();
133+
});
134+
135+
it("skips snapshot apply when no snapshot at all", async () => {
136+
mockResumeFromLog.mockResolvedValue({
137+
conversation: [],
138+
latestSnapshot: null,
139+
snapshotApplied: false,
140+
interrupted: false,
141+
logEntryCount: 0,
142+
});
143+
144+
const deps = createDeps();
145+
const saga = new HandoffSaga(deps);
146+
const result = await saga.run(createInput());
147+
148+
expect(result.success).toBe(true);
149+
if (!result.success) return;
150+
expect(result.data.snapshotApplied).toBe(false);
151+
expect(deps.applyTreeSnapshot).not.toHaveBeenCalled();
152+
});
153+
154+
it("seeds local logs when cloudLogUrl is present", async () => {
155+
mockResumeFromLog.mockResolvedValue({
156+
conversation: [],
157+
latestSnapshot: null,
158+
snapshotApplied: false,
159+
interrupted: false,
160+
logEntryCount: 0,
161+
});
162+
163+
const deps = createDeps();
164+
const saga = new HandoffSaga(deps);
165+
await saga.run(createInput());
166+
167+
expect(deps.seedLocalLogs).toHaveBeenCalledWith(
168+
"run-1",
169+
"https://logs.example.com/run-1.ndjson",
170+
);
171+
});
172+
173+
it("skips seeding logs when cloudLogUrl is falsy", async () => {
174+
mockResumeFromLog.mockResolvedValue({
175+
conversation: [],
176+
latestSnapshot: null,
177+
snapshotApplied: false,
178+
interrupted: false,
179+
logEntryCount: 0,
180+
});
181+
182+
const apiClient = {
183+
getTaskRun: vi.fn().mockResolvedValue({ log_url: undefined }),
184+
};
185+
const deps = createDeps({
186+
createApiClient: vi.fn().mockReturnValue(apiClient),
187+
});
188+
const saga = new HandoffSaga(deps);
189+
await saga.run(createInput());
190+
191+
expect(deps.seedLocalLogs).not.toHaveBeenCalled();
192+
});
193+
194+
it("sets pending context with handoff summary", async () => {
195+
mockResumeFromLog.mockResolvedValue({
196+
conversation: [
197+
{ role: "user", content: [{ type: "text", text: "hello" }] },
198+
],
199+
latestSnapshot: null,
200+
snapshotApplied: false,
201+
interrupted: false,
202+
logEntryCount: 1,
203+
});
204+
mockFormatConversation.mockReturnValue("User said hello");
205+
206+
const deps = createDeps();
207+
const saga = new HandoffSaga(deps);
208+
await saga.run(createInput());
209+
210+
expect(deps.setPendingContext).toHaveBeenCalledWith(
211+
"run-1",
212+
expect.stringContaining("resuming a previous conversation"),
213+
);
214+
expect(deps.setPendingContext).toHaveBeenCalledWith(
215+
"run-1",
216+
expect.stringContaining("could not be restored"),
217+
);
218+
});
219+
220+
it("context mentions files restored when snapshot applied", async () => {
221+
mockResumeFromLog.mockResolvedValue({
222+
conversation: [],
223+
latestSnapshot: createSnapshot(),
224+
snapshotApplied: false,
225+
interrupted: false,
226+
logEntryCount: 0,
227+
});
228+
229+
const deps = createDeps();
230+
const saga = new HandoffSaga(deps);
231+
await saga.run(createInput());
232+
233+
expect(deps.setPendingContext).toHaveBeenCalledWith(
234+
"run-1",
235+
expect.stringContaining("fully restored"),
236+
);
237+
});
238+
239+
it("passes sessionId and adapter through to reconnectSession", async () => {
240+
mockResumeFromLog.mockResolvedValue({
241+
conversation: [],
242+
latestSnapshot: null,
243+
snapshotApplied: false,
244+
interrupted: false,
245+
logEntryCount: 0,
246+
});
247+
248+
const deps = createDeps();
249+
const saga = new HandoffSaga(deps);
250+
await saga.run(createInput({ sessionId: "ses-abc", adapter: "codex" }));
251+
252+
expect(deps.reconnectSession).toHaveBeenCalledWith(
253+
expect.objectContaining({
254+
sessionId: "ses-abc",
255+
adapter: "codex",
256+
}),
257+
);
258+
});
259+
260+
it("emits progress events in order", async () => {
261+
mockResumeFromLog.mockResolvedValue({
262+
conversation: [],
263+
latestSnapshot: createSnapshot(),
264+
snapshotApplied: false,
265+
interrupted: false,
266+
logEntryCount: 0,
267+
});
268+
269+
const deps = createDeps();
270+
const saga = new HandoffSaga(deps);
271+
await saga.run(createInput());
272+
273+
const progressCalls = (deps.onProgress as ReturnType<typeof vi.fn>).mock
274+
.calls;
275+
const steps = progressCalls.map((call: unknown[]) => call[0]);
276+
expect(steps).toEqual([
277+
"fetching_logs",
278+
"applying_snapshot",
279+
"spawning_agent",
280+
"complete",
281+
]);
282+
});
283+
284+
describe("rollbacks", () => {
285+
it("rolls back workspace mode when spawn_agent fails", async () => {
286+
mockResumeFromLog.mockResolvedValue({
287+
conversation: [],
288+
latestSnapshot: null,
289+
snapshotApplied: false,
290+
interrupted: false,
291+
logEntryCount: 0,
292+
});
293+
294+
const deps = createDeps({
295+
reconnectSession: vi.fn().mockRejectedValue(new Error("spawn failed")),
296+
});
297+
const saga = new HandoffSaga(deps);
298+
const result = await saga.run(createInput());
299+
300+
expect(result.success).toBe(false);
301+
if (result.success) return;
302+
expect(result.failedStep).toBe("spawn_agent");
303+
expect(deps.updateWorkspaceMode).toHaveBeenCalledWith("task-1", "cloud");
304+
});
305+
306+
it("kills session on rollback if spawn partially succeeded", async () => {
307+
mockResumeFromLog.mockResolvedValue({
308+
conversation: [],
309+
latestSnapshot: null,
310+
snapshotApplied: false,
311+
interrupted: false,
312+
logEntryCount: 0,
313+
});
314+
315+
const deps = createDeps({
316+
reconnectSession: vi.fn().mockResolvedValue(null),
317+
});
318+
const saga = new HandoffSaga(deps);
319+
const result = await saga.run(createInput());
320+
321+
expect(result.success).toBe(false);
322+
if (result.success) return;
323+
expect(result.failedStep).toBe("spawn_agent");
324+
});
325+
326+
it("fails at fetch_and_rebuild without rolling back workspace", async () => {
327+
mockResumeFromLog.mockRejectedValue(new Error("API down"));
328+
329+
const deps = createDeps();
330+
const saga = new HandoffSaga(deps);
331+
const result = await saga.run(createInput());
332+
333+
expect(result.success).toBe(false);
334+
if (result.success) return;
335+
expect(result.failedStep).toBe("fetch_and_rebuild");
336+
expect(deps.updateWorkspaceMode).not.toHaveBeenCalled();
337+
expect(deps.reconnectSession).not.toHaveBeenCalled();
338+
});
339+
});
340+
});

0 commit comments

Comments
 (0)