Skip to content

Commit 3d4147e

Browse files
committed
Implement agent proxy
1 parent 7a4f19a commit 3d4147e

7 files changed

Lines changed: 358 additions & 189 deletions

File tree

apps/code/src/main/services/cloud-task/service.test.ts

Lines changed: 156 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@ import { CloudTaskEvent } from "./schemas";
33

44
const mockNetFetch = vi.hoisted(() => vi.fn());
55
const mockStreamFetch = vi.hoisted(() => vi.fn());
6+
const mockStreamTokenFetch = vi.hoisted(() => vi.fn());
67

78
// The service now uses global fetch for BOTH authenticated API calls (JSON)
89
// and SSE streaming. The two used to be distinct (net.fetch vs global fetch).
9-
// To preserve the existing test fixtures, route by URL: /stream/ → stream mock,
10-
// everything else → API mock.
10+
// Route by URL: /stream_token/ → token mock (read-leg resolution), the stream leg
11+
// (Django /stream/ or proxy /v1/runs/:run/stream) → stream mock, everything else → API mock.
12+
// The token mock has a Django-path default so existing fixtures (which never set it) are untouched.
1113
const fetchRouter = vi.hoisted(() =>
1214
vi.fn((input: string | Request, init?: RequestInit) => {
1315
const url = typeof input === "string" ? input : input.url;
14-
const impl = url.includes("/stream/") ? mockStreamFetch : mockNetFetch;
16+
const impl = url.includes("/stream_token/")
17+
? mockStreamTokenFetch
18+
: /\/stream(\/|\?|$)/.test(url)
19+
? mockStreamFetch
20+
: mockNetFetch;
1521
return impl(input, init);
1622
}),
1723
);
@@ -97,6 +103,13 @@ describe("CloudTaskService", () => {
97103
service = new CloudTaskService(mockAuthService as never);
98104
mockNetFetch.mockReset();
99105
mockStreamFetch.mockReset();
106+
mockStreamTokenFetch.mockReset();
107+
// Default read-leg resolution: no proxy URL, so the stream reads from Django directly.
108+
mockStreamTokenFetch.mockImplementation(() =>
109+
Promise.resolve(
110+
createJsonResponse({ token: "test-token", stream_base_url: null }),
111+
),
112+
);
100113
mockAuthService.authenticatedFetch.mockReset();
101114
vi.stubGlobal("fetch", fetchRouter);
102115

@@ -604,6 +617,113 @@ describe("CloudTaskService", () => {
604617
).toBe(true);
605618
});
606619

620+
it("stops without reconnecting when the server emits stream-end on a non-terminal run", async () => {
621+
vi.useFakeTimers();
622+
623+
// Run status stays non-terminal the whole time. Pre-durable-contract, a clean EOF on a
624+
// non-terminal run reconnects (see the test above); the stream-end sentinel must override that.
625+
mockNetFetch.mockImplementation((input: string | Request) => {
626+
const url = typeof input === "string" ? input : input.url;
627+
if (url.includes("/session_logs/")) {
628+
return Promise.resolve(
629+
createJsonResponse([], 200, { "X-Has-More": "false" }),
630+
);
631+
}
632+
return Promise.resolve(
633+
createJsonResponse({
634+
id: "run-1",
635+
status: "in_progress",
636+
stage: "build",
637+
output: null,
638+
error_message: null,
639+
branch: "main",
640+
updated_at: "2026-01-01T00:00:00Z",
641+
}),
642+
);
643+
});
644+
645+
mockStreamFetch.mockImplementation(() =>
646+
Promise.resolve(
647+
createSseResponse(
648+
'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"hi"}}}\n\nevent: stream-end\ndata: {}\n\n',
649+
),
650+
),
651+
);
652+
653+
service.watch({
654+
taskId: "task-1",
655+
runId: "run-1",
656+
apiHost: "https://app.example.com",
657+
teamId: 2,
658+
});
659+
660+
const hasWatcher = (): boolean =>
661+
(service as unknown as { watchers: Map<string, unknown> }).watchers.has(
662+
"task-1:run-1",
663+
);
664+
665+
await waitFor(() => mockStreamFetch.mock.calls.length === 1);
666+
// Let the reconnect delay (2s base) elapse; with stream-end honored, none is scheduled.
667+
await vi.advanceTimersByTimeAsync(10_000);
668+
669+
expect(mockStreamFetch.mock.calls.length).toBe(1);
670+
await waitFor(() => !hasWatcher());
671+
});
672+
673+
it("reads via the agent-proxy with a Bearer token when the server resolves a base url", async () => {
674+
vi.useFakeTimers();
675+
676+
mockStreamTokenFetch.mockImplementation(() =>
677+
Promise.resolve(
678+
createJsonResponse({
679+
token: "proxy-token",
680+
stream_base_url: "https://proxy.example",
681+
}),
682+
),
683+
);
684+
685+
mockNetFetch.mockImplementation((input: string | Request) => {
686+
const url = typeof input === "string" ? input : input.url;
687+
if (url.includes("/session_logs/")) {
688+
return Promise.resolve(
689+
createJsonResponse([], 200, { "X-Has-More": "false" }),
690+
);
691+
}
692+
return Promise.resolve(
693+
createJsonResponse({
694+
id: "run-1",
695+
status: "in_progress",
696+
stage: "build",
697+
output: null,
698+
error_message: null,
699+
branch: "main",
700+
updated_at: "2026-01-01T00:00:00Z",
701+
}),
702+
);
703+
});
704+
705+
mockStreamFetch.mockImplementation(() =>
706+
Promise.resolve(createSseResponse("event: stream-end\ndata: {}\n\n")),
707+
);
708+
709+
service.watch({
710+
taskId: "task-1",
711+
runId: "run-1",
712+
apiHost: "https://app.example.com",
713+
teamId: 2,
714+
});
715+
716+
await waitFor(() => mockStreamFetch.mock.calls.length === 1);
717+
718+
const [calledUrl, init] = mockStreamFetch.mock.calls[0];
719+
expect(String(calledUrl)).toMatch(
720+
/^https:\/\/proxy\.example\/v1\/runs\/run-1\/stream(\?|$)/,
721+
);
722+
expect((init?.headers as Record<string, string>)?.Authorization).toBe(
723+
"Bearer proxy-token",
724+
);
725+
});
726+
607727
it("fails the watcher after exhausting the cumulative reconnect budget on clean-EOF loops", async () => {
608728
vi.useFakeTimers();
609729

@@ -720,10 +840,10 @@ describe("CloudTaskService", () => {
720840
10_000,
721841
);
722842

723-
expect(mockStreamFetch.mock.calls.length).toBe(6);
724-
// 2 bootstrap calls + 1 post-bootstrap status verification + 6
725-
// handleStreamCompletion calls (one per stream error)
726-
expect(mockNetFetch).toHaveBeenCalledTimes(9);
843+
expect(mockStreamFetch.mock.calls.length).toBe(10);
844+
// Status is no longer polled per reconnect. Only the 2 bootstrap calls plus the single
845+
// post-bootstrap verification touch the status endpoint; reconnects never do.
846+
expect(mockNetFetch.mock.calls.length).toBeLessThanOrEqual(3);
727847
expect(updates).toContainEqual({
728848
taskId: "task-1",
729849
runId: "run-1",
@@ -892,12 +1012,12 @@ describe("CloudTaskService", () => {
8921012
});
8931013
});
8941014

895-
it("stops the watcher without reconnecting once the run is terminal", async () => {
1015+
it("reconnects on a clean EOF even after the run status goes terminal (status-unaware)", async () => {
8961016
vi.useFakeTimers();
8971017

898-
const updates: unknown[] = [];
899-
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
900-
1018+
// Bootstrap sees an active run (so it streams); every later status fetch reports terminal.
1019+
// Pre-decoupling, a clean EOF on a terminal run stopped the watch. Now run status is never
1020+
// consulted to decide reconnects, so the clean EOFs keep reconnecting until stream-end.
9011021
let statusFetchCount = 0;
9021022
mockNetFetch.mockImplementation((input: string | Request) => {
9031023
const url = typeof input === "string" ? input : input.url;
@@ -907,7 +1027,6 @@ describe("CloudTaskService", () => {
9071027
);
9081028
}
9091029
statusFetchCount += 1;
910-
// Bootstrap sees an active run; the post-stream status check sees terminal.
9111030
return Promise.resolve(
9121031
createJsonResponse({
9131032
id: "run-1",
@@ -936,22 +1055,14 @@ describe("CloudTaskService", () => {
9361055
});
9371056

9381057
await waitFor(() => mockStreamFetch.mock.calls.length === 1);
939-
await vi.advanceTimersByTimeAsync(10_000);
1058+
await waitFor(() => mockStreamFetch.mock.calls.length >= 3, 20_000);
9401059

941-
expect(updates).toContainEqual(
942-
expect.objectContaining({
943-
taskId: "task-1",
944-
runId: "run-1",
945-
kind: "status",
946-
status: "completed",
947-
}),
948-
);
949-
expect(mockStreamFetch.mock.calls.length).toBe(1);
1060+
// Terminal status did not stop the watch; the watcher is still reconnecting.
9501061
expect(
9511062
(service as unknown as { watchers: Map<string, unknown> }).watchers.has(
9521063
"task-1:run-1",
9531064
),
954-
).toBe(false);
1065+
).toBe(true);
9551066
});
9561067

9571068
it("surfaces a retryable error when the backend errors even on a long-lived stream", async () => {
@@ -1013,8 +1124,8 @@ describe("CloudTaskService", () => {
10131124
});
10141125

10151126
await waitFor(() => mockStreamFetch.mock.calls.length === 1);
1016-
// Drive >= 6 long-lived-then-backend-error cycles (65s open + backoff each).
1017-
await vi.advanceTimersByTimeAsync(65_000 * 7 + 70_000);
1127+
// Drive >= 10 long-lived-then-backend-error cycles (65s open + backoff each).
1128+
await vi.advanceTimersByTimeAsync(65_000 * 11 + 70_000);
10181129
await waitFor(
10191130
() =>
10201131
updates.some(
@@ -1322,16 +1433,19 @@ describe("CloudTaskService", () => {
13221433
expect(getWatcher()?.failed).toBe(false);
13231434
});
13241435

1325-
it("surfaces an error instead of retrying forever when run-state fetch keeps failing after a clean stream end", async () => {
1436+
it("does not poll run status per reconnect on clean EOFs (status-unaware)", async () => {
13261437
vi.useFakeTimers();
13271438

1328-
const updates: unknown[] = [];
1329-
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
1330-
1331-
// Bootstrap succeeds (run + empty backlog); every subsequent run-state
1332-
// fetch returns 500 (a non-fatal status -> fetchTaskRun resolves null).
1333-
mockNetFetch
1334-
.mockResolvedValueOnce(
1439+
let statusFetchCount = 0;
1440+
mockNetFetch.mockImplementation((input: string | Request) => {
1441+
const url = typeof input === "string" ? input : input.url;
1442+
if (url.includes("/session_logs/")) {
1443+
return Promise.resolve(
1444+
createJsonResponse([], 200, { "X-Has-More": "false" }),
1445+
);
1446+
}
1447+
statusFetchCount += 1;
1448+
return Promise.resolve(
13351449
createJsonResponse({
13361450
id: "run-1",
13371451
status: "in_progress",
@@ -1341,85 +1455,26 @@ describe("CloudTaskService", () => {
13411455
branch: "main",
13421456
updated_at: "2026-01-01T00:00:00Z",
13431457
}),
1344-
) // bootstrap: fetchTaskRun
1345-
.mockResolvedValueOnce(
1346-
createJsonResponse([], 200, { "X-Has-More": "false" }),
1347-
) // bootstrap: fetchSessionLogs
1348-
.mockImplementation(() =>
1349-
Promise.resolve(createJsonResponse({ detail: "boom" }, 500)),
1350-
);
1351-
1352-
// First connection is held open so bootstrap can finish; the test then
1353-
// closes it cleanly. Every later connection ends cleanly on its own, so the
1354-
// only thing that can fail is the post-stream run-state fetch (500).
1355-
let streamCall = 0;
1356-
const firstControllerRef: {
1357-
current: ReadableStreamDefaultController<Uint8Array> | null;
1358-
} = { current: null };
1359-
mockStreamFetch.mockImplementation(() => {
1360-
streamCall += 1;
1361-
const stream = new ReadableStream<Uint8Array>({
1362-
start(controller) {
1363-
if (streamCall === 1) {
1364-
firstControllerRef.current = controller;
1365-
} else {
1366-
controller.close();
1367-
}
1368-
},
1369-
});
1370-
return Promise.resolve(
1371-
new Response(stream, {
1372-
status: 200,
1373-
headers: { "Content-Type": "text/event-stream" },
1374-
}),
13751458
);
13761459
});
13771460

1461+
// Every connection ends cleanly with no stream-end sentinel, forcing reconnect after reconnect.
1462+
mockStreamFetch.mockImplementation(() =>
1463+
Promise.resolve(createSseResponse("")),
1464+
);
1465+
13781466
service.watch({
13791467
taskId: "task-1",
13801468
runId: "run-1",
13811469
apiHost: "https://app.example.com",
13821470
teamId: 2,
13831471
});
13841472

1385-
// Wait for bootstrap to emit its snapshot and hold the live connection open.
1386-
await waitFor(
1387-
() =>
1388-
!!firstControllerRef.current &&
1389-
updates.some(
1390-
(u) =>
1391-
typeof u === "object" &&
1392-
u !== null &&
1393-
(u as { kind?: string }).kind === "snapshot",
1394-
),
1395-
);
1396-
1397-
// Close the live stream cleanly: each clean end now fetches run state, which
1398-
// 500s. The reconnect must charge the budget so it eventually gives up.
1399-
firstControllerRef.current?.close();
1473+
await waitFor(() => mockStreamFetch.mock.calls.length >= 5, 20_000);
14001474

1401-
// Budget is 5 attempts (2s + 4s + 8s + 16s + 30s + 30s of backoff).
1402-
await vi.advanceTimersByTimeAsync(120_000);
1403-
await waitFor(
1404-
() =>
1405-
updates.some(
1406-
(u) =>
1407-
typeof u === "object" &&
1408-
u !== null &&
1409-
(u as { kind?: string }).kind === "error",
1410-
),
1411-
20_000,
1412-
);
1413-
1414-
expect(updates).toContainEqual({
1415-
taskId: "task-1",
1416-
runId: "run-1",
1417-
kind: "error",
1418-
errorTitle: "Cloud run state unavailable",
1419-
errorMessage:
1420-
"Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.",
1421-
retryable: true,
1422-
});
1475+
// Bootstrap fetches status once and the post-bootstrap verification once more; reconnects add
1476+
// none. Pre-decoupling, every clean EOF polled status, so this count would climb with reconnects.
1477+
expect(statusFetchCount).toBeLessThanOrEqual(2);
14231478
});
14241479

14251480
const guardedFetchStatusExpectations = [

0 commit comments

Comments
 (0)