repoburg 1.3.199 → 1.3.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/agent-pane/backend/data.db +0 -0
  2. package/agent-pane/backend/data.db-shm +0 -0
  3. package/agent-pane/backend/data.db-wal +0 -0
  4. package/agent-pane/backend/dist/index.js +1425 -272
  5. package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
  6. package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
  7. package/agent-pane/backend/src/db/index.ts +6 -0
  8. package/agent-pane/backend/src/index.ts +5 -2
  9. package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
  10. package/agent-pane/backend/src/providers/index.ts +3 -1
  11. package/agent-pane/backend/src/repoburg/action-mapping.ts +216 -0
  12. package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
  13. package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +262 -0
  14. package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +271 -0
  15. package/agent-pane/backend/src/repoburg/backend-resolver.ts +246 -0
  16. package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +159 -0
  17. package/agent-pane/backend/src/repoburg/repoburg-client.ts +181 -0
  18. package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +723 -0
  19. package/agent-pane/backend/src/repoburg/repoburg-provider.ts +422 -0
  20. package/agent-pane/backend/src/repoburg/turn-projector.test.ts +475 -0
  21. package/agent-pane/backend/src/repoburg/turn-projector.ts +537 -0
  22. package/agent-pane/backend/src/routes/providers.test.ts +18 -1
  23. package/agent-pane/backend/src/routes/providers.ts +19 -1
  24. package/agent-pane/backend/src/routes/sessions.ts +12 -0
  25. package/agent-pane/backend/src/routes/threads.ts +21 -6
  26. package/agent-pane/backend/src/routes/turns.test.ts +45 -1
  27. package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-34WaZYsW.css} +1 -1
  28. package/agent-pane/frontend/dist/assets/index-y7xlT86e.js +362 -0
  29. package/agent-pane/frontend/dist/index.html +2 -2
  30. package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
  31. package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
  32. package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
  33. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
  34. package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
  35. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
  36. package/agent-pane/frontend/src/constants/models.ts +37 -0
  37. package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
  38. package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
  39. package/agent-pane/frontend/src/store/store.test.ts +35 -0
  40. package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
  41. package/agent-pane/frontend/src/styles/app.css +10 -1
  42. package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
  43. package/agent-pane-bug-repro-no-agent-reply.png +0 -0
  44. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +20 -0
  45. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +112 -0
  46. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
  47. package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
  48. package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
  49. package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
  50. package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
  51. package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
  52. package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
  53. package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +77 -0
  54. package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
  55. package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
  56. package/backend/dist/src/app.module.js +4 -0
  57. package/backend/dist/src/app.module.js.map +1 -1
  58. package/backend/dist/src/interactive-chat/chat.module.js +4 -0
  59. package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
  60. package/backend/dist/src/interactive-chat/chat.service.d.ts +3 -1
  61. package/backend/dist/src/interactive-chat/chat.service.js +33 -6
  62. package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
  63. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
  64. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
  65. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
  66. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
  67. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
  68. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
  69. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +35 -2
  70. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
  71. package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
  72. package/backend/dist/src/llm-provider/ollama-llm.provider.js +19 -1
  73. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  74. package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
  75. package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
  76. package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
  77. package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
  78. package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
  79. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  80. package/daemon/dist/index.js +0 -0
  81. package/package.json +1 -1
  82. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ createSseParser,
4
+ parseSseFrame,
5
+ streamAgentRuntimeMessages,
6
+ } from './agent-runtime-client.ts';
7
+ import type { AgentRuntimeMessage } from './agent-runtime-client.ts';
8
+
9
+ const enc = new TextEncoder();
10
+
11
+ describe('parseSseFrame', () => {
12
+ it('extracts a single data line', () => {
13
+ expect(parseSseFrame('data: {"a":1}')).toBe('{"a":1}');
14
+ });
15
+
16
+ it('strips one optional space after the colon', () => {
17
+ expect(parseSseFrame('data: {"a":1}')).toBe('{"a":1}');
18
+ expect(parseSseFrame('data:{"a":1}')).toBe('{"a":1}');
19
+ });
20
+
21
+ it('ignores comment (heartbeat) lines and returns null when no data', () => {
22
+ expect(parseSseFrame(': ping')).toBeNull();
23
+ expect(parseSseFrame(': ping\ndata: x')).toBe('x');
24
+ });
25
+
26
+ it('joins multiple data lines with newlines', () => {
27
+ expect(parseSseFrame('data: a\ndata: b')).toBe('a\nb');
28
+ });
29
+ });
30
+
31
+ describe('createSseParser', () => {
32
+ it('splits complete frames and buffers partial ones across chunks', () => {
33
+ const parse = createSseParser();
34
+ expect(parse('data: {"type":"a"}\n\ndata: {"ty')).toEqual(['{"type":"a"}']);
35
+ expect(parse('pe":"b"}\n\n')).toEqual(['{"type":"b"}']);
36
+ });
37
+
38
+ it('handles CRLF line endings', () => {
39
+ const parse = createSseParser();
40
+ expect(parse('data: {"type":"a"}\r\n\r\n')).toEqual(['{"type":"a"}']);
41
+ });
42
+
43
+ it('ignores heartbeat-only frames', () => {
44
+ const parse = createSseParser();
45
+ expect(parse(': ping\n\n')).toEqual([]);
46
+ });
47
+ });
48
+
49
+ describe('streamAgentRuntimeMessages', () => {
50
+ const fetchMock = vi.fn();
51
+ const originalFetch = globalThis.fetch;
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ globalThis.fetch = fetchMock as never;
56
+ });
57
+
58
+ afterEach(() => {
59
+ globalThis.fetch = originalFetch;
60
+ });
61
+
62
+ /** Build an SSE Response body from raw chunks. */
63
+ function sseBody(chunks: string[], onAbort?: (error: () => void) => void): ReadableStream<Uint8Array> {
64
+ return new ReadableStream<Uint8Array>({
65
+ start(controller) {
66
+ for (const c of chunks) controller.enqueue(enc.encode(c));
67
+ if (!onAbort) controller.close();
68
+ else onAbort(() => controller.error(new DOMException('Aborted', 'AbortError')));
69
+ },
70
+ });
71
+ }
72
+
73
+ function sseResponse(body: ReadableStream<Uint8Array>): Response {
74
+ return { ok: true, status: 200, body } as never;
75
+ }
76
+
77
+ const systemFrame = 'data: {"type":"system","subtype":"init","sessionId":"rs-1","modelId":null}\n\n';
78
+ const assistantFrame = 'data: {"type":"assistant","sessionId":"rs-1","inputId":"in-1","content":[]}\n\n';
79
+ const resultFrame =
80
+ 'data: {"type":"result","subtype":"success","sessionId":"rs-1","is_error":false,"result":"done","num_rounds":1}\n\n';
81
+
82
+ it('delivers messages in order and stops at the terminal result', async () => {
83
+ fetchMock.mockResolvedValue(
84
+ sseResponse(sseBody([systemFrame, ': ping\n\n', assistantFrame, resultFrame])),
85
+ );
86
+ const messages: AgentRuntimeMessage[] = [];
87
+ const onActivity = vi.fn();
88
+
89
+ const reason = await streamAgentRuntimeMessages(
90
+ 'http://repoburg.test',
91
+ 'rs-1',
92
+ (m) => messages.push(m),
93
+ { onActivity, reconnectDelayMs: 1 },
94
+ );
95
+
96
+ expect(reason).toBe('result');
97
+ expect(messages.map((m) => m.type)).toEqual(['system', 'assistant', 'result']);
98
+ // Activity fired for the received bytes (chunk coalescing makes the exact
99
+ // read count implementation-dependent — at least once).
100
+ expect(onActivity.mock.calls.length).toBeGreaterThanOrEqual(1);
101
+ expect(fetchMock).toHaveBeenCalledTimes(1);
102
+ expect(fetchMock).toHaveBeenCalledWith(
103
+ 'http://repoburg.test/agent-runtime/sessions/rs-1/messages?stream=1',
104
+ expect.objectContaining({ method: 'GET' }),
105
+ );
106
+ });
107
+
108
+ it('parses frames split across chunk boundaries', async () => {
109
+ fetchMock.mockResolvedValue(
110
+ sseResponse(
111
+ sseBody([
112
+ 'data: {"type":"res',
113
+ 'ult","subtype":"succ',
114
+ 'ess","sessionId":"rs-1","is_error":false,"result":"d","num_rounds":1}\n\n',
115
+ ]),
116
+ ),
117
+ );
118
+ const messages: AgentRuntimeMessage[] = [];
119
+
120
+ const reason = await streamAgentRuntimeMessages(
121
+ 'http://repoburg.test',
122
+ 'rs-1',
123
+ (m) => messages.push(m),
124
+ { reconnectDelayMs: 1 },
125
+ );
126
+
127
+ expect(reason).toBe('result');
128
+ expect(messages).toHaveLength(1);
129
+ expect(messages[0].type).toBe('result');
130
+ });
131
+
132
+ it('reconnects after the server closes the stream without a result', async () => {
133
+ fetchMock
134
+ .mockResolvedValueOnce(sseResponse(sseBody([assistantFrame]))) // closes early
135
+ .mockResolvedValueOnce(sseResponse(sseBody([resultFrame])));
136
+ const messages: AgentRuntimeMessage[] = [];
137
+
138
+ const reason = await streamAgentRuntimeMessages(
139
+ 'http://repoburg.test',
140
+ 'rs-1',
141
+ (m) => messages.push(m),
142
+ { reconnectDelayMs: 1 },
143
+ );
144
+
145
+ expect(reason).toBe('result');
146
+ expect(fetchMock).toHaveBeenCalledTimes(2);
147
+ expect(messages.map((m) => m.type)).toEqual(['assistant', 'result']);
148
+ });
149
+
150
+ it('reconnects after a fetch failure', async () => {
151
+ fetchMock
152
+ .mockRejectedValueOnce(new Error('ECONNREFUSED'))
153
+ .mockResolvedValueOnce(sseResponse(sseBody([resultFrame])));
154
+
155
+ const reason = await streamAgentRuntimeMessages(
156
+ 'http://repoburg.test',
157
+ 'rs-1',
158
+ () => {},
159
+ { reconnectDelayMs: 1 },
160
+ );
161
+
162
+ expect(reason).toBe('result');
163
+ expect(fetchMock).toHaveBeenCalledTimes(2);
164
+ });
165
+
166
+ it('reconnects on non-OK responses other than 404', async () => {
167
+ fetchMock
168
+ .mockResolvedValueOnce({ ok: false, status: 502 } as never)
169
+ .mockResolvedValueOnce(sseResponse(sseBody([resultFrame])));
170
+
171
+ const reason = await streamAgentRuntimeMessages(
172
+ 'http://repoburg.test',
173
+ 'rs-1',
174
+ () => {},
175
+ { reconnectDelayMs: 1 },
176
+ );
177
+
178
+ expect(reason).toBe('result');
179
+ expect(fetchMock).toHaveBeenCalledTimes(2);
180
+ });
181
+
182
+ it('returns session-not-found on 404 without retrying', async () => {
183
+ fetchMock.mockResolvedValue({ ok: false, status: 404 } as never);
184
+
185
+ const reason = await streamAgentRuntimeMessages(
186
+ 'http://repoburg.test',
187
+ 'rs-1',
188
+ () => {},
189
+ { reconnectDelayMs: 1 },
190
+ );
191
+
192
+ expect(reason).toBe('session-not-found');
193
+ expect(fetchMock).toHaveBeenCalledTimes(1);
194
+ });
195
+
196
+ it('returns aborted when the signal is already aborted', async () => {
197
+ const controller = new AbortController();
198
+ controller.abort();
199
+
200
+ const reason = await streamAgentRuntimeMessages(
201
+ 'http://repoburg.test',
202
+ 'rs-1',
203
+ () => {},
204
+ { signal: controller.signal, reconnectDelayMs: 1 },
205
+ );
206
+
207
+ expect(reason).toBe('aborted');
208
+ expect(fetchMock).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('returns aborted when the signal aborts mid-stream', async () => {
212
+ const controller = new AbortController();
213
+ // Simulate real fetch semantics: aborting errors the body stream.
214
+ fetchMock.mockResolvedValue(
215
+ sseResponse(
216
+ sseBody([assistantFrame], (error) => {
217
+ controller.signal.addEventListener('abort', () => error());
218
+ }),
219
+ ),
220
+ );
221
+ const messages: AgentRuntimeMessage[] = [];
222
+
223
+ const promise = streamAgentRuntimeMessages(
224
+ 'http://repoburg.test',
225
+ 'rs-1',
226
+ (m) => messages.push(m),
227
+ { signal: controller.signal, reconnectDelayMs: 1 },
228
+ );
229
+ // Let the first chunk deliver, then abort.
230
+ await new Promise((r) => setTimeout(r, 10));
231
+ controller.abort();
232
+
233
+ const reason = await promise;
234
+
235
+ expect(reason).toBe('aborted');
236
+ expect(messages.map((m) => m.type)).toEqual(['assistant']);
237
+ });
238
+
239
+ it('skips malformed JSON frames without failing the stream', async () => {
240
+ fetchMock.mockResolvedValue(
241
+ sseResponse(sseBody(['data: not-json\n\n', resultFrame])),
242
+ );
243
+ const messages: AgentRuntimeMessage[] = [];
244
+
245
+ const reason = await streamAgentRuntimeMessages(
246
+ 'http://repoburg.test',
247
+ 'rs-1',
248
+ (m) => messages.push(m),
249
+ { reconnectDelayMs: 1 },
250
+ );
251
+
252
+ expect(reason).toBe('result');
253
+ expect(messages.map((m) => m.type)).toEqual(['result']);
254
+ });
255
+
256
+ it('requests deltas off (?stream=0) when deltas=false', async () => {
257
+ fetchMock.mockResolvedValue(sseResponse(sseBody([resultFrame])));
258
+
259
+ await streamAgentRuntimeMessages(
260
+ 'http://repoburg.test',
261
+ 'rs-1',
262
+ () => {},
263
+ { deltas: false, reconnectDelayMs: 1 },
264
+ );
265
+
266
+ expect(fetchMock).toHaveBeenCalledWith(
267
+ 'http://repoburg.test/agent-runtime/sessions/rs-1/messages?stream=0',
268
+ expect.anything(),
269
+ );
270
+ });
271
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Agent-runtime SSE client — consumes the root backend's agent-runtime
3
+ * message stream (Claude-Code-SDK-style ordered messages):
4
+ * GET /agent-runtime/sessions/:sessionId/messages?stream=1
5
+ *
6
+ * One JSON message per SSE `data:` line; heartbeat comments (`: ping`) keep
7
+ * the connection alive and count as activity for the stream watchdog.
8
+ * The stream ends when the terminal `result` message arrives; connection
9
+ * drops before it trigger reconnect with backoff until aborted.
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Message types (mirror backend/src/agent-runtime/agent-runtime.types.ts)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface AgentRuntimeUsage {
17
+ input_tokens: number;
18
+ output_tokens: number;
19
+ cache_read_input_tokens: number;
20
+ cache_creation_input_tokens: number;
21
+ }
22
+
23
+ export interface AgentRuntimeActionInfo {
24
+ id: string;
25
+ action_type: string;
26
+ file_path: string | null;
27
+ command_string: string | null;
28
+ status: string;
29
+ server_name: string | null;
30
+ tool_name: string | null;
31
+ arguments: string | null;
32
+ plain: string | null;
33
+ order_of_execution: number;
34
+ }
35
+
36
+ export type AgentRuntimeContentBlock =
37
+ | { type: 'text'; text: string }
38
+ | { type: 'thinking'; thinking: string }
39
+ | {
40
+ type: 'tool_use';
41
+ id: string;
42
+ name: string;
43
+ input: Record<string, unknown> | null;
44
+ };
45
+
46
+ export interface AgentRuntimeToolResultBlock {
47
+ type: 'tool_result';
48
+ tool_use_id: string;
49
+ content: string;
50
+ is_error: boolean;
51
+ action: AgentRuntimeActionInfo;
52
+ }
53
+
54
+ export interface SystemInitMessage {
55
+ type: 'system';
56
+ subtype: 'init';
57
+ sessionId: string;
58
+ modelId: string | null;
59
+ }
60
+
61
+ export interface StreamEventMessage {
62
+ type: 'stream_event';
63
+ sessionId: string;
64
+ inputId: string;
65
+ delta: { type: 'text' | 'thinking'; text: string };
66
+ }
67
+
68
+ export interface AssistantMessage {
69
+ type: 'assistant';
70
+ sessionId: string;
71
+ inputId: string;
72
+ content: AgentRuntimeContentBlock[];
73
+ usage?: AgentRuntimeUsage;
74
+ }
75
+
76
+ export interface UserMessage {
77
+ type: 'user';
78
+ sessionId: string;
79
+ inputId: string;
80
+ content: AgentRuntimeToolResultBlock[];
81
+ }
82
+
83
+ export interface ResultMessage {
84
+ type: 'result';
85
+ subtype: 'success' | 'error_during_execution' | 'aborted';
86
+ sessionId: string;
87
+ is_error: boolean;
88
+ result: string;
89
+ usage?: AgentRuntimeUsage;
90
+ num_rounds: number;
91
+ errors?: string[];
92
+ }
93
+
94
+ export type AgentRuntimeMessage =
95
+ | SystemInitMessage
96
+ | StreamEventMessage
97
+ | AssistantMessage
98
+ | UserMessage
99
+ | ResultMessage;
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // SSE stream consumer
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export type MessageHandler = (message: AgentRuntimeMessage) => void;
106
+
107
+ export interface StreamOptions {
108
+ /** Aborting closes the connection and stops reconnect attempts. */
109
+ signal?: AbortSignal;
110
+ /** false = request the stream without deltas (?stream=0). Default true. */
111
+ deltas?: boolean;
112
+ /** Called on ANY received bytes (message or heartbeat) — watchdog reset. */
113
+ onActivity?: () => void;
114
+ /** Backoff between reconnect attempts (ms). Default 2000. */
115
+ reconnectDelayMs?: number;
116
+ }
117
+
118
+ /** Why the stream ended. */
119
+ export type StreamEndReason = 'result' | 'aborted' | 'session-not-found';
120
+
121
+ /** Parse one SSE frame (already split on blank line) into its data payload.
122
+ * Comment (`:`) and non-data lines are ignored; multiple `data:` lines join. */
123
+ export function parseSseFrame(frame: string): string | null {
124
+ const dataLines: string[] = [];
125
+ for (const line of frame.split('\n')) {
126
+ const trimmed = line.replace(/\r$/, '');
127
+ if (trimmed.startsWith(':')) continue; // heartbeat comment
128
+ if (trimmed.startsWith('data:')) {
129
+ dataLines.push(trimmed.slice(5).replace(/^ /, ''));
130
+ }
131
+ }
132
+ if (dataLines.length === 0) return null;
133
+ return dataLines.join('\n');
134
+ }
135
+
136
+ /** Split a raw chunk stream into complete SSE frames. Yields data payloads. */
137
+ export function createSseParser(): (chunk: string) => string[] {
138
+ let buffer = '';
139
+ return (chunk: string) => {
140
+ // Normalize CRLF line endings — frames split on blank LF lines.
141
+ buffer += chunk.replace(/\r\n/g, '\n');
142
+ const payloads: string[] = [];
143
+ let boundary: number;
144
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
145
+ const frame = buffer.slice(0, boundary);
146
+ buffer = buffer.slice(boundary + 2);
147
+ const data = parseSseFrame(frame);
148
+ if (data !== null) payloads.push(data);
149
+ }
150
+ return payloads;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Consume the agent-runtime message stream for one session until the
156
+ * terminal `result` message, an abort, or a missing session (404).
157
+ * Connection drops reconnect with backoff; `onActivity` fires on every
158
+ * received frame so callers can run a stream watchdog.
159
+ */
160
+ export async function streamAgentRuntimeMessages(
161
+ baseUrl: string,
162
+ sessionId: string,
163
+ onMessage: MessageHandler,
164
+ opts: StreamOptions = {},
165
+ ): Promise<StreamEndReason> {
166
+ const deltas = opts.deltas !== false;
167
+ const delayMs = opts.reconnectDelayMs ?? 2000;
168
+ const url = `${baseUrl.replace(/\/+$/, '')}/agent-runtime/sessions/${sessionId}/messages?stream=${deltas ? 1 : 0}`;
169
+
170
+ for (;;) {
171
+ if (opts.signal?.aborted) return 'aborted';
172
+
173
+ let response: Response;
174
+ try {
175
+ response = await fetch(url, { method: 'GET', signal: opts.signal });
176
+ } catch (err) {
177
+ if (opts.signal?.aborted || (err as Error).name === 'AbortError') {
178
+ return 'aborted';
179
+ }
180
+ await reconnectDelay(delayMs, opts.signal);
181
+ continue;
182
+ }
183
+
184
+ if (response.status === 404) return 'session-not-found';
185
+ if (!response.ok || !response.body) {
186
+ await reconnectDelay(delayMs, opts.signal);
187
+ continue;
188
+ }
189
+
190
+ // Read the body frame by frame.
191
+ const reason: StreamEndReason | null = await readBody(
192
+ response.body,
193
+ onMessage,
194
+ opts,
195
+ );
196
+ if (reason !== null) {
197
+ await response.body.cancel().catch(() => {});
198
+ return reason;
199
+ }
200
+
201
+ // Server closed the stream without a result — reconnect.
202
+ await reconnectDelay(delayMs, opts.signal);
203
+ }
204
+ }
205
+
206
+ /** Read one response body to its end. Returns a terminal reason or null
207
+ * (body ended without result / was aborted → caller reconnects). */
208
+ async function readBody(
209
+ body: ReadableStream<Uint8Array>,
210
+ onMessage: MessageHandler,
211
+ opts: StreamOptions,
212
+ ): Promise<StreamEndReason | null> {
213
+ const parse = createSseParser();
214
+ const decoder = new TextDecoder();
215
+ const reader = body.getReader();
216
+
217
+ try {
218
+ for (;;) {
219
+ const { done, value } = await reader.read();
220
+ if (done) return null;
221
+ opts.onActivity?.();
222
+ for (const payload of parse(decoder.decode(value, { stream: true }))) {
223
+ let message: AgentRuntimeMessage | null = null;
224
+ try {
225
+ message = JSON.parse(payload) as AgentRuntimeMessage;
226
+ } catch {
227
+ continue; // Malformed frame — skip.
228
+ }
229
+ if (!message || typeof message.type !== 'string') continue;
230
+ onMessage(message);
231
+ if (message.type === 'result') return 'result';
232
+ }
233
+ }
234
+ } catch (err) {
235
+ if (opts.signal?.aborted || (err as Error).name === 'AbortError') {
236
+ return 'aborted';
237
+ }
238
+ return null; // Read error — caller reconnects.
239
+ } finally {
240
+ try {
241
+ reader.releaseLock();
242
+ } catch {
243
+ // Reader already released.
244
+ }
245
+ }
246
+ }
247
+
248
+ /** Wait the reconnect delay; resolve early when the signal aborts. */
249
+ function reconnectDelay(delayMs: number, signal?: AbortSignal): Promise<void> {
250
+ return new Promise((resolve) => {
251
+ if (signal?.aborted) return resolve();
252
+ const timer = setTimeout(resolve, delayMs);
253
+ signal?.addEventListener(
254
+ 'abort',
255
+ () => {
256
+ clearTimeout(timer);
257
+ resolve();
258
+ },
259
+ { once: true },
260
+ );
261
+ });
262
+ }