@codespring-app/use-agent 0.1.0 → 0.2.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-08-30
4
+
5
+ - Add origin-bound, single-use browser WebSocket ticket exchange.
6
+ - Add durable multi-page replay and the public `AgentSession.connect` API.
7
+ - Add contiguous cursor tracking, replay/live deduplication, gap recovery, and
8
+ reconnect state to the React session store.
9
+ - Align message and tool reducers with the canonical runtime event protocol.
10
+ - Add the reusable `AgentEventBuffer` and a production live-replay smoke.
11
+
12
+ ## 0.1.0 — 2026-08-30
13
+
14
+ - Publish the initial public server and React SDK preview.
package/README.md CHANGED
@@ -75,6 +75,12 @@ The `/browser` endpoint is intentional: it accepts only short-lived client
75
75
  tokens and is the only runtime surface with browser CORS. Server API keys stay
76
76
  on the endpoint root and must never be shipped to a browser.
77
77
 
78
+ The React session store loads durable history, then switches to a live
79
+ WebSocket using a 30-second, single-use ticket. It keeps one contiguous event
80
+ cursor, removes replay/live duplicates, repairs gaps over HTTP, and reconnects
81
+ with bounded jitter. `useAgentSession` exposes `connection` as `idle`,
82
+ `connecting`, `live`, `reconnecting`, or `closed` for custom status UI.
83
+
78
84
  The default Paper experience renders assistant replies as document content on
79
85
  an edge-to-edge canvas, user messages as quiet trailing wells, tool calls as
80
86
  compact inspectable activity rows, and the live-edge composer without a shadow.
@@ -153,6 +159,28 @@ mixed with client-owned components.
153
159
  The browser entrypoint never accepts an API key. A trusted application backend
154
160
  must issue short-lived, origin-bound client tokens.
155
161
 
162
+ For a non-React browser UI, connect to the same durable stream directly:
163
+
164
+ ```ts
165
+ const session = agentClient.sessions.get(sessionId);
166
+ const connection = await session.connect({
167
+ after: lastAppliedCursor,
168
+ onEvent(event) {
169
+ // Persist or reduce the event, then advance lastAppliedCursor.
170
+ },
171
+ onReplayComplete(cursor) {
172
+ console.log("Live at", cursor);
173
+ },
174
+ });
175
+
176
+ connection.close();
177
+ ```
178
+
179
+ The SDK performs the authenticated ticket exchange and automatically follows
180
+ multi-page WebSocket replay. `AgentEventBuffer` is available to headless
181
+ clients that want the same contiguous-cursor, deduplication, and gap-detection
182
+ rules as the React store.
183
+
156
184
  ## Local showcase
157
185
 
158
186
  ```sh
@@ -42,6 +42,101 @@ var Transport = class {
42
42
  }
43
43
  return payload;
44
44
  }
45
+ async connectSession(sessionId, options) {
46
+ if (!this.options.browser) {
47
+ throw new TypeError("Browser WebSocket connections require createBrowserClient");
48
+ }
49
+ const after = options.after ?? 0;
50
+ if (!Number.isSafeInteger(after) || after < 0) {
51
+ throw new TypeError("after must be a non-negative safe integer");
52
+ }
53
+ const issued = await this.request(
54
+ `/v1/sessions/${encodeURIComponent(sessionId)}/websocket-tickets`,
55
+ {
56
+ method: "POST",
57
+ body: JSON.stringify({ after }),
58
+ ...options.signal === void 0 ? {} : { signal: options.signal }
59
+ }
60
+ );
61
+ const socketUrl = new URL(
62
+ `${this.endpoint}/v1/sessions/${encodeURIComponent(sessionId)}/connect`
63
+ );
64
+ socketUrl.protocol = socketUrl.protocol === "https:" ? "wss:" : "ws:";
65
+ socketUrl.searchParams.set("ticket", issued.ticket);
66
+ const createSocket = this.options.webSocket ?? defaultWebSocketFactory;
67
+ const socket = createSocket(socketUrl.toString());
68
+ let cursor = after;
69
+ let opened = false;
70
+ let settled = false;
71
+ return new Promise((resolve, reject) => {
72
+ const connection = {
73
+ get cursor() {
74
+ return cursor;
75
+ },
76
+ close: (code = 1e3, reason = "client closed") => socket.close(code, reason)
77
+ };
78
+ const failBeforeOpen = (error) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ reject(error);
82
+ };
83
+ socket.addEventListener("open", () => {
84
+ opened = true;
85
+ if (settled) return;
86
+ settled = true;
87
+ resolve(connection);
88
+ });
89
+ socket.addEventListener("message", (message) => {
90
+ try {
91
+ const parsed = parseWebSocketServerMessage(message.data);
92
+ if (parsed.type === "event") {
93
+ cursor = Math.max(cursor, parsed.event.id);
94
+ options.onEvent(parsed.event);
95
+ return;
96
+ }
97
+ if (parsed.type === "replay.completed") {
98
+ cursor = Math.max(cursor, parsed.cursor);
99
+ if (parsed.hasMore) {
100
+ socket.send(JSON.stringify({ type: "replay", after: cursor }));
101
+ } else {
102
+ options.onReplayComplete?.(cursor);
103
+ }
104
+ return;
105
+ }
106
+ const error = new AgentError(parsed.message, 0, parsed.code);
107
+ options.onError?.(error);
108
+ socket.close(1008, "server rejected connection");
109
+ } catch (error) {
110
+ const normalized = error instanceof Error ? error : new Error(String(error));
111
+ options.onError?.(normalized);
112
+ socket.close(1008, "invalid server message");
113
+ if (!opened) failBeforeOpen(normalized);
114
+ }
115
+ });
116
+ socket.addEventListener("error", () => {
117
+ const error = new AgentError("WebSocket connection failed", 0, "websocket_failed");
118
+ options.onError?.(error);
119
+ if (!opened) failBeforeOpen(error);
120
+ });
121
+ socket.addEventListener("close", (event) => {
122
+ options.onClose?.(event);
123
+ if (!opened) {
124
+ failBeforeOpen(
125
+ new AgentError(
126
+ "WebSocket closed before connecting",
127
+ 0,
128
+ "websocket_closed"
129
+ )
130
+ );
131
+ }
132
+ });
133
+ if (options.signal) {
134
+ const closeForAbort = () => socket.close(1e3, "request aborted");
135
+ if (options.signal.aborted) closeForAbort();
136
+ else options.signal.addEventListener("abort", closeForAbort, { once: true });
137
+ }
138
+ });
139
+ }
45
140
  async fetchWithToken(path, init) {
46
141
  const headers = new Headers(init.headers);
47
142
  headers.set("Accept", "application/json");
@@ -169,6 +264,9 @@ var AgentSession = class {
169
264
  }
170
265
  );
171
266
  }
267
+ connect(options) {
268
+ return this.transport.connectSession(this.id, options);
269
+ }
172
270
  };
173
271
  var AgentClient = class {
174
272
  constructor(transport) {
@@ -193,6 +291,7 @@ function createClient(options) {
193
291
  new Transport({
194
292
  endpoint: options.endpoint,
195
293
  token: staticTokenProvider(options.apiKey),
294
+ browser: false,
196
295
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
197
296
  })
198
297
  );
@@ -206,15 +305,82 @@ function createBrowserClient(options) {
206
305
  options.clientTokenTtlMs ?? 6e4,
207
306
  options.refreshSkewMs ?? 3e4
208
307
  ),
209
- ...options.fetch === void 0 ? {} : { fetch: options.fetch }
308
+ browser: true,
309
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch },
310
+ ...options.webSocket === void 0 ? {} : { webSocket: options.webSocket }
210
311
  })
211
312
  );
212
313
  }
314
+ function defaultWebSocketFactory(url) {
315
+ if (typeof globalThis.WebSocket !== "function") {
316
+ throw new TypeError("A WebSocket implementation is required");
317
+ }
318
+ return new globalThis.WebSocket(url);
319
+ }
320
+ function parseWebSocketServerMessage(value) {
321
+ if (typeof value !== "string") throw new TypeError("WebSocket message must be JSON text");
322
+ const parsed = JSON.parse(value);
323
+ if (!isObject(parsed) || typeof parsed.type !== "string") {
324
+ throw new TypeError("WebSocket message is invalid");
325
+ }
326
+ if (parsed.type === "event" && isAgentEvent(parsed.event)) {
327
+ return { type: "event", event: parsed.event };
328
+ }
329
+ if (parsed.type === "replay.completed" && Number.isSafeInteger(parsed.cursor) && parsed.cursor >= 0 && typeof parsed.hasMore === "boolean") {
330
+ return {
331
+ type: "replay.completed",
332
+ cursor: parsed.cursor,
333
+ hasMore: parsed.hasMore
334
+ };
335
+ }
336
+ if (parsed.type === "error" && typeof parsed.code === "string" && typeof parsed.message === "string") {
337
+ return { type: "error", code: parsed.code, message: parsed.message };
338
+ }
339
+ throw new TypeError("WebSocket message is invalid");
340
+ }
341
+ function isAgentEvent(value) {
342
+ return isObject(value) && value.schemaVersion === 1 && Number.isSafeInteger(value.id) && value.id > 0 && typeof value.sessionId === "string" && Number.isSafeInteger(value.attempt) && value.attempt >= 0 && typeof value.type === "string" && typeof value.createdAt === "string" && (!("turnId" in value) || typeof value.turnId === "string");
343
+ }
344
+
345
+ // src/event-buffer.ts
346
+ var AgentEventBuffer = class {
347
+ ordered = [];
348
+ currentCursor = 0;
349
+ get cursor() {
350
+ return this.currentCursor;
351
+ }
352
+ get events() {
353
+ return this.ordered;
354
+ }
355
+ merge(incoming) {
356
+ const accepted = [];
357
+ const duplicateIds = [];
358
+ for (const event of incoming) {
359
+ if (event.id <= this.currentCursor) {
360
+ duplicateIds.push(event.id);
361
+ continue;
362
+ }
363
+ const expected = this.currentCursor + 1;
364
+ if (event.id !== expected) {
365
+ return { accepted, duplicateIds, gap: { expected, received: event.id } };
366
+ }
367
+ this.ordered.push(event);
368
+ this.currentCursor = event.id;
369
+ accepted.push(event);
370
+ }
371
+ return { accepted, duplicateIds };
372
+ }
373
+ reset() {
374
+ this.ordered = [];
375
+ this.currentCursor = 0;
376
+ }
377
+ };
213
378
 
214
379
  export {
215
380
  AgentError,
216
381
  AgentSession,
217
382
  AgentClient,
218
383
  createClient,
219
- createBrowserClient
384
+ createBrowserClient,
385
+ AgentEventBuffer
220
386
  };
@@ -40,6 +40,47 @@ interface ListEventsResponse {
40
40
  cursor: number;
41
41
  hasMore: boolean;
42
42
  }
43
+ interface CreateWebSocketTicketResponse {
44
+ ticket: string;
45
+ expiresAt: string;
46
+ }
47
+ type WebSocketServerMessage = {
48
+ type: "event";
49
+ event: AgentEvent;
50
+ } | {
51
+ type: "replay.completed";
52
+ cursor: number;
53
+ hasMore: boolean;
54
+ } | {
55
+ type: "error";
56
+ code: string;
57
+ message: string;
58
+ };
59
+ interface AgentWebSocketEventMap {
60
+ open: Event;
61
+ message: MessageEvent;
62
+ error: Event;
63
+ close: CloseEvent;
64
+ }
65
+ interface AgentWebSocket {
66
+ readonly readyState: number;
67
+ addEventListener<K extends keyof AgentWebSocketEventMap>(type: K, listener: (event: AgentWebSocketEventMap[K]) => void): void;
68
+ send(data: string): void;
69
+ close(code?: number, reason?: string): void;
70
+ }
71
+ type AgentWebSocketFactory = (url: string) => AgentWebSocket;
72
+ interface AgentConnectionOptions {
73
+ after?: number;
74
+ signal?: AbortSignal;
75
+ onEvent: (event: AgentEvent) => void;
76
+ onReplayComplete?: (cursor: number) => void;
77
+ onError?: (error: Error) => void;
78
+ onClose?: (event: CloseEvent) => void;
79
+ }
80
+ interface AgentConnection {
81
+ readonly cursor: number;
82
+ close(code?: number, reason?: string): void;
83
+ }
43
84
  /** Tenant-scoped model profile configured in the CodeSpring control plane. */
44
85
  type ModelProfileId = string;
45
86
  interface AgentToolReference {
@@ -95,6 +136,8 @@ interface BrowserAgentClientOptions {
95
136
  /** Refresh before expiry. Defaults to 30 seconds and is bounded for short tokens. */
96
137
  refreshSkewMs?: number;
97
138
  fetch?: FetchLike;
139
+ /** Injectable for tests or non-DOM browser runtimes. Defaults to the global WebSocket constructor. */
140
+ webSocket?: AgentWebSocketFactory;
98
141
  }
99
142
  type ClientTokenResult = string | {
100
143
  token: string;
@@ -117,6 +160,8 @@ interface TransportOptions {
117
160
  endpoint: string;
118
161
  token: TokenProvider;
119
162
  fetch?: FetchLike;
163
+ webSocket?: AgentWebSocketFactory;
164
+ browser: boolean;
120
165
  }
121
166
  declare class Transport {
122
167
  private readonly options;
@@ -124,6 +169,7 @@ declare class Transport {
124
169
  readonly fetchImplementation: FetchLike;
125
170
  constructor(options: TransportOptions);
126
171
  request<T>(path: string, init?: RequestInit): Promise<T>;
172
+ connectSession(sessionId: string, options: AgentConnectionOptions): Promise<AgentConnection>;
127
173
  private fetchWithToken;
128
174
  }
129
175
  declare class AgentSession {
@@ -134,6 +180,7 @@ declare class AgentSession {
134
180
  submit(content: string, options?: SubmitOptions): Promise<SubmitTurnResponse>;
135
181
  events(after?: number, limit?: number, options?: RequestOptions): Promise<ListEventsResponse>;
136
182
  cancel(turnId: string, options?: RequestOptions): Promise<TurnStatusResponse>;
183
+ connect(options: AgentConnectionOptions): Promise<AgentConnection>;
137
184
  }
138
185
  interface TurnStatusResponse {
139
186
  sessionId: string;
@@ -153,4 +200,4 @@ declare function createClient(options: AgentClientOptions): AgentClient;
153
200
  /** Browser-safe client used by the React subpath with short-lived client tokens. */
154
201
  declare function createBrowserClient(options: BrowserAgentClientOptions): AgentClient;
155
202
 
156
- export { type AgentDefinition as A, type BrowserAgentClientOptions as B, type CreateAgentOptions as C, type FetchLike as F, type ListEventsResponse as L, type ModelProfileId as M, type RequestOptions as R, type SessionSnapshot as S, type TurnStatus as T, AgentClient as a, type AgentClientOptions as b, AgentError as c, type AgentEvent as d, type AgentMcpServerReference as e, AgentSession as f, type AgentSkillReference as g, type AgentToolReference as h, type ClientTokenResult as i, type CreateSessionResponse as j, type SubmitOptions as k, type SubmitTurnResponse as l, createBrowserClient as m, createClient as n };
203
+ export { type AgentDefinition as A, type BrowserAgentClientOptions as B, type CreateAgentOptions as C, type FetchLike as F, type ListEventsResponse as L, type ModelProfileId as M, type RequestOptions as R, type SessionSnapshot as S, type TurnStatus as T, type WebSocketServerMessage as W, type AgentEvent as a, AgentClient as b, type AgentClientOptions as c, type AgentConnection as d, type AgentConnectionOptions as e, AgentError as f, type AgentMcpServerReference as g, AgentSession as h, type AgentSkillReference as i, type AgentToolReference as j, type AgentWebSocket as k, type AgentWebSocketEventMap as l, type AgentWebSocketFactory as m, type ClientTokenResult as n, type CreateSessionResponse as o, type CreateWebSocketTicketResponse as p, type SubmitOptions as q, type SubmitTurnResponse as r, createBrowserClient as s, createClient as t };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,25 @@
1
- import { C as CreateAgentOptions, A as AgentDefinition } from './client-Bz3eVQXx.js';
2
- export { a as AgentClient, b as AgentClientOptions, c as AgentError, d as AgentEvent, e as AgentMcpServerReference, f as AgentSession, g as AgentSkillReference, h as AgentToolReference, B as BrowserAgentClientOptions, i as ClientTokenResult, j as CreateSessionResponse, F as FetchLike, L as ListEventsResponse, M as ModelProfileId, R as RequestOptions, S as SessionSnapshot, k as SubmitOptions, l as SubmitTurnResponse, T as TurnStatus, m as createBrowserClient, n as createClient } from './client-Bz3eVQXx.js';
1
+ import { C as CreateAgentOptions, A as AgentDefinition, a as AgentEvent } from './client-DcCIhqkm.js';
2
+ export { b as AgentClient, c as AgentClientOptions, d as AgentConnection, e as AgentConnectionOptions, f as AgentError, g as AgentMcpServerReference, h as AgentSession, i as AgentSkillReference, j as AgentToolReference, k as AgentWebSocket, l as AgentWebSocketEventMap, m as AgentWebSocketFactory, B as BrowserAgentClientOptions, n as ClientTokenResult, o as CreateSessionResponse, p as CreateWebSocketTicketResponse, F as FetchLike, L as ListEventsResponse, M as ModelProfileId, R as RequestOptions, S as SessionSnapshot, q as SubmitOptions, r as SubmitTurnResponse, T as TurnStatus, W as WebSocketServerMessage, s as createBrowserClient, t as createClient } from './client-DcCIhqkm.js';
3
3
 
4
4
  /** Defines a portable agent revision without importing runtime implementation code. */
5
5
  declare function createAgent(options: CreateAgentOptions): AgentDefinition;
6
6
 
7
- export { AgentDefinition, CreateAgentOptions, createAgent };
7
+ interface AgentEventMergeResult {
8
+ accepted: AgentEvent[];
9
+ duplicateIds: number[];
10
+ gap?: {
11
+ expected: number;
12
+ received: number;
13
+ };
14
+ }
15
+ /** Maintains the single contiguous durable cursor used by replay and live delivery. */
16
+ declare class AgentEventBuffer {
17
+ private ordered;
18
+ private currentCursor;
19
+ get cursor(): number;
20
+ get events(): readonly AgentEvent[];
21
+ merge(incoming: readonly AgentEvent[]): AgentEventMergeResult;
22
+ reset(): void;
23
+ }
24
+
25
+ export { AgentDefinition, AgentEvent, AgentEventBuffer, type AgentEventMergeResult, CreateAgentOptions, createAgent };
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  AgentClient,
3
3
  AgentError,
4
+ AgentEventBuffer,
4
5
  AgentSession,
5
6
  createBrowserClient,
6
7
  createClient
7
- } from "./chunk-FAD2XMPA.js";
8
+ } from "./chunk-CC365XBZ.js";
8
9
 
9
10
  // src/agent.ts
10
11
  var identifierPattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u;
@@ -41,6 +42,7 @@ function createAgent(options) {
41
42
  export {
42
43
  AgentClient,
43
44
  AgentError,
45
+ AgentEventBuffer,
44
46
  AgentSession,
45
47
  createAgent,
46
48
  createBrowserClient,
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { CSSProperties, ReactNode, PropsWithChildren } from 'react';
3
- import { a as AgentClient, f as AgentSession, S as SessionSnapshot, d as AgentEvent, k as SubmitOptions, l as SubmitTurnResponse, B as BrowserAgentClientOptions } from './client-Bz3eVQXx.js';
4
- export { m as createBrowserClient } from './client-Bz3eVQXx.js';
3
+ import { b as AgentClient, h as AgentSession, S as SessionSnapshot, a as AgentEvent, q as SubmitOptions, r as SubmitTurnResponse, B as BrowserAgentClientOptions } from './client-DcCIhqkm.js';
4
+ export { s as createBrowserClient } from './client-DcCIhqkm.js';
5
5
 
6
6
  interface AgentTheme {
7
7
  canvas: string;
@@ -91,15 +91,17 @@ interface CreateAgentClientOptions {
91
91
  credentials?: RequestCredentials;
92
92
  clientTokenTtlMs?: number;
93
93
  refreshSkewMs?: number;
94
+ webSocket?: BrowserAgentClientOptions["webSocket"];
94
95
  }
95
96
  /** Creates one stable browser client with an in-memory, deduplicated client-token cache. */
96
- declare function createAgentClient({ endpoint, clientTokenEndpoint, fetch: fetchImplementation, credentials, clientTokenTtlMs, refreshSkewMs, }: CreateAgentClientOptions): AgentClient;
97
+ declare function createAgentClient({ endpoint, clientTokenEndpoint, fetch: fetchImplementation, credentials, clientTokenTtlMs, refreshSkewMs, webSocket, }: CreateAgentClientOptions): AgentClient;
97
98
  declare function useAgentTheme(): AgentTheme;
98
99
  declare function useAgentCopy(): AgentCopy;
99
100
  type AgentMessageStatus = "completed" | "streaming" | "failed" | "cancelled";
100
101
  interface AgentChatMessage {
101
102
  id: string;
102
103
  turnId: string;
104
+ attempt: number;
103
105
  role: "user" | "assistant";
104
106
  content: string;
105
107
  status: AgentMessageStatus;
@@ -113,6 +115,7 @@ declare function reduceAgentMessages(events: readonly AgentEvent[]): AgentChatMe
113
115
  declare function reduceAgentToolCalls(events: readonly AgentEvent[]): AgentToolCall[];
114
116
  interface SessionState {
115
117
  status: "idle" | "loading" | "ready" | "error";
118
+ connection: "idle" | "connecting" | "live" | "reconnecting" | "closed";
116
119
  snapshot: SessionSnapshot | null;
117
120
  events: AgentEvent[];
118
121
  messages: AgentChatMessage[];
@@ -123,15 +126,24 @@ declare class SessionStore {
123
126
  readonly session: AgentSession;
124
127
  private state;
125
128
  private readonly listeners;
129
+ private readonly eventBuffer;
126
130
  private controller;
127
- private refreshTimer;
131
+ private connection;
132
+ private reconnectTimer;
133
+ private generation;
134
+ private reconnectAttempt;
128
135
  constructor(session: AgentSession);
129
136
  subscribe: (listener: () => void) => () => void;
130
137
  getSnapshot: () => SessionState;
131
138
  getServerSnapshot: () => SessionState;
132
139
  private setState;
133
- private scheduleRefresh;
134
140
  refresh(): Promise<void>;
141
+ private replayDurableEvents;
142
+ private openLiveConnection;
143
+ private receiveLiveEvent;
144
+ private publishBuffer;
145
+ private scheduleReconnect;
146
+ private stop;
135
147
  dispose(): void;
136
148
  }
137
149
  interface AgentSessionResult extends SessionState {
@@ -161,12 +173,17 @@ interface AgentToolCallProps extends StyledProps {
161
173
  interface AgentToolCall {
162
174
  id: string;
163
175
  turnId: string;
176
+ operationId?: string;
177
+ callId?: string;
178
+ revision?: string;
179
+ risk?: string;
164
180
  name: string;
165
181
  label: string;
166
182
  summary?: string;
167
183
  status: AgentToolCallStatus;
168
184
  input?: unknown;
169
185
  output?: unknown;
186
+ error?: unknown;
170
187
  createdAt: string;
171
188
  eventId: number;
172
189
  }
package/dist/react.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
+ AgentError,
3
+ AgentEventBuffer,
2
4
  createBrowserClient
3
- } from "./chunk-FAD2XMPA.js";
5
+ } from "./chunk-CC365XBZ.js";
4
6
 
5
7
  // src/react.tsx
6
8
  import {
@@ -182,7 +184,8 @@ function createAgentClient({
182
184
  fetch: fetchImplementation,
183
185
  credentials = "same-origin",
184
186
  clientTokenTtlMs,
185
- refreshSkewMs
187
+ refreshSkewMs,
188
+ webSocket
186
189
  }) {
187
190
  return createBrowserClient({
188
191
  endpoint,
@@ -206,7 +209,8 @@ function createAgentClient({
206
209
  },
207
210
  ...fetchImplementation === void 0 ? {} : { fetch: fetchImplementation },
208
211
  ...clientTokenTtlMs === void 0 ? {} : { clientTokenTtlMs },
209
- ...refreshSkewMs === void 0 ? {} : { refreshSkewMs }
212
+ ...refreshSkewMs === void 0 ? {} : { refreshSkewMs },
213
+ ...webSocket === void 0 ? {} : { webSocket }
210
214
  });
211
215
  }
212
216
  function useAgentTheme() {
@@ -229,11 +233,13 @@ function reduceAgentMessages(events) {
229
233
  for (const event of events) {
230
234
  if (!event.turnId) continue;
231
235
  const inputId = `${event.turnId}:user`;
232
- const outputId = `${event.turnId}:assistant:${event.attempt}`;
236
+ const itemId = stringField(event.data, "itemId");
237
+ const outputId = `${event.turnId}:assistant:${itemId ?? `attempt-${event.attempt}`}`;
233
238
  if (event.type === "message.input") {
234
239
  messages.set(inputId, {
235
240
  id: inputId,
236
241
  turnId: event.turnId,
242
+ attempt: event.attempt,
237
243
  role: "user",
238
244
  content: stringField(event.data, "content") ?? "",
239
245
  status: "completed",
@@ -246,6 +252,7 @@ function reduceAgentMessages(events) {
246
252
  messages.set(outputId, {
247
253
  id: outputId,
248
254
  turnId: event.turnId,
255
+ attempt: event.attempt,
249
256
  role: "assistant",
250
257
  content: "",
251
258
  status: "streaming",
@@ -270,6 +277,7 @@ function reduceAgentMessages(events) {
270
277
  messages.set(outputId, {
271
278
  id: outputId,
272
279
  turnId: event.turnId,
280
+ attempt: event.attempt,
273
281
  role: "assistant",
274
282
  content: stringField(event.data, "content") ?? current?.content ?? "",
275
283
  status: "completed",
@@ -279,15 +287,20 @@ function reduceAgentMessages(events) {
279
287
  continue;
280
288
  }
281
289
  if (event.type === "message.attempt_abandoned") {
282
- messages.delete(outputId);
290
+ for (const [id, message] of messages) {
291
+ if (message.turnId === event.turnId && message.role === "assistant" && message.attempt === event.attempt && message.status === "streaming") {
292
+ messages.delete(id);
293
+ }
294
+ }
283
295
  continue;
284
296
  }
285
297
  if (event.type === "turn.failed" || event.type === "turn.cancelled") {
286
- const current = [...messages.values()].filter((message) => message.turnId === event.turnId && message.role === "assistant").at(-1);
298
+ const current = [...messages.values()].filter((message) => message.turnId === event.turnId && message.role === "assistant" && message.attempt === event.attempt).at(-1);
287
299
  const terminalId = current?.id ?? `${event.turnId}:assistant:${event.attempt}`;
288
300
  messages.set(terminalId, {
289
301
  id: terminalId,
290
302
  turnId: event.turnId,
303
+ attempt: event.attempt,
291
304
  role: "assistant",
292
305
  content: current?.content ?? "",
293
306
  status: event.type === "turn.failed" ? "failed" : "cancelled",
@@ -301,30 +314,49 @@ function reduceAgentMessages(events) {
301
314
  function reduceAgentToolCalls(events) {
302
315
  const calls = /* @__PURE__ */ new Map();
303
316
  for (const event of events) {
304
- if (!event.turnId || !event.type.startsWith("tool.call.")) continue;
305
- const eventName = stringField(event.data, "name");
306
- const callId = stringField(event.data, "toolCallId") ?? `${event.turnId}:${event.attempt}:${eventName ?? "tool"}`;
307
- const current = calls.get(callId);
317
+ if (!event.turnId) continue;
318
+ const lifecycle = toolLifecycle(event.type);
319
+ if (!lifecycle) continue;
320
+ const operationId = stringField(event.data, "operationId");
321
+ const callId = stringField(event.data, "callId") ?? stringField(event.data, "toolCallId");
322
+ const eventName = stringField(event.data, "toolName") ?? stringField(event.data, "name");
323
+ const revision = stringField(event.data, "toolRevision");
324
+ const risk = stringField(event.data, "risk");
325
+ const id = operationId ?? callId ?? `${event.turnId}:${event.attempt}:${eventName ?? "tool"}`;
326
+ const current = calls.get(id);
308
327
  const name = eventName ?? current?.name ?? "tool";
309
328
  const summary = stringField(event.data, "summary");
310
- const status = event.type === "tool.call.completed" ? "completed" : event.type === "tool.call.failed" ? "failed" : event.type === "tool.call.approval_required" ? "approval_required" : event.type === "tool.call.started" ? "running" : "proposed";
311
- calls.set(callId, {
312
- id: callId,
329
+ const status = lifecycle === "completed" ? "completed" : lifecycle === "failed" ? "failed" : lifecycle === "approval_required" || lifecycle === "proposed" && stringField(event.data, "approval") === "required" ? "approval_required" : lifecycle === "started" ? "running" : "proposed";
330
+ const input = unknownField(event.data, "arguments") ?? unknownField(event.data, "input");
331
+ const output = unknownField(event.data, "output");
332
+ const failure = unknownField(event.data, "error");
333
+ calls.set(id, {
334
+ id,
313
335
  turnId: event.turnId,
336
+ ...operationId === void 0 ? current?.operationId === void 0 ? {} : { operationId: current.operationId } : { operationId },
337
+ ...callId === void 0 ? current?.callId === void 0 ? {} : { callId: current.callId } : { callId },
338
+ ...revision === void 0 ? current?.revision === void 0 ? {} : { revision: current.revision } : { revision },
339
+ ...risk === void 0 ? current?.risk === void 0 ? {} : { risk: current.risk } : { risk },
314
340
  name,
315
341
  label: stringField(event.data, "label") ?? current?.label ?? name,
316
342
  ...summary === void 0 ? current?.summary === void 0 ? {} : { summary: current.summary } : { summary },
317
343
  status,
318
- ...unknownField(event.data, "input") === void 0 ? current?.input === void 0 ? {} : { input: current.input } : { input: unknownField(event.data, "input") },
319
- ...unknownField(event.data, "output") === void 0 ? current?.output === void 0 ? {} : { output: current.output } : { output: unknownField(event.data, "output") },
344
+ ...input === void 0 ? current?.input === void 0 ? {} : { input: current.input } : { input },
345
+ ...output === void 0 ? current?.output === void 0 ? {} : { output: current.output } : { output },
346
+ ...failure === void 0 ? current?.error === void 0 ? {} : { error: current.error } : { error: failure },
320
347
  createdAt: current?.createdAt ?? event.createdAt,
321
348
  eventId: event.id
322
349
  });
323
350
  }
324
351
  return [...calls.values()].sort((left, right) => left.eventId - right.eventId);
325
352
  }
353
+ function toolLifecycle(type) {
354
+ const canonical = /^(?:tool|tool\.call)\.(proposed|started|completed|failed|approval_required)$/u.exec(type)?.[1];
355
+ return canonical === "proposed" || canonical === "started" || canonical === "completed" || canonical === "failed" || canonical === "approval_required" ? canonical : null;
356
+ }
326
357
  var initialSessionState = {
327
358
  status: "idle",
359
+ connection: "idle",
328
360
  snapshot: null,
329
361
  events: [],
330
362
  messages: [],
@@ -338,17 +370,19 @@ var SessionStore = class {
338
370
  session;
339
371
  state = initialSessionState;
340
372
  listeners = /* @__PURE__ */ new Set();
373
+ eventBuffer = new AgentEventBuffer();
341
374
  controller = null;
342
- refreshTimer = null;
375
+ connection = null;
376
+ reconnectTimer = null;
377
+ generation = 0;
378
+ reconnectAttempt = 0;
343
379
  subscribe = (listener) => {
344
380
  this.listeners.add(listener);
345
381
  if (this.listeners.size === 1) void this.refresh();
346
382
  return () => {
347
383
  this.listeners.delete(listener);
348
384
  if (this.listeners.size === 0) {
349
- this.controller?.abort();
350
- if (this.refreshTimer) clearTimeout(this.refreshTimer);
351
- this.refreshTimer = null;
385
+ this.stop();
352
386
  }
353
387
  };
354
388
  };
@@ -358,61 +392,222 @@ var SessionStore = class {
358
392
  this.state = state;
359
393
  for (const listener of this.listeners) listener();
360
394
  }
361
- scheduleRefresh() {
362
- if (this.refreshTimer) clearTimeout(this.refreshTimer);
363
- const isWorking = this.state.snapshot?.turns.some(
364
- (turn) => turn.status === "queued" || turn.status === "running"
365
- );
366
- this.refreshTimer = isWorking && this.listeners.size > 0 ? setTimeout(() => void this.refresh(), 1e3) : null;
367
- }
368
395
  async refresh() {
396
+ const generation = ++this.generation;
369
397
  this.controller?.abort();
398
+ this.connection?.close(1e3, "refreshing");
399
+ this.connection = null;
400
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
401
+ this.reconnectTimer = null;
370
402
  this.controller = new AbortController();
371
403
  const { signal } = this.controller;
372
404
  this.setState({
373
405
  ...this.state,
374
406
  status: this.state.snapshot ? "ready" : "loading",
407
+ connection: "connecting",
375
408
  error: null
376
409
  });
377
410
  try {
378
411
  const snapshot = await this.session.get({ signal });
379
- const events = [];
380
- let cursor = 0;
381
- let hasMore = true;
382
- let pages = 0;
383
- while (hasMore && pages < 100) {
384
- const page = await this.session.events(cursor, 100, { signal });
385
- events.push(...page.events);
386
- cursor = page.cursor;
387
- hasMore = page.hasMore;
388
- pages += 1;
412
+ if (snapshot.cursor < this.eventBuffer.cursor) this.eventBuffer.reset();
413
+ await this.replayDurableEvents(signal);
414
+ if (signal.aborted || generation !== this.generation) return;
415
+ this.publishBuffer(snapshot, "connecting");
416
+ await this.openLiveConnection(generation, signal);
417
+ } catch (error) {
418
+ if (signal.aborted || generation !== this.generation) return;
419
+ this.scheduleReconnect(error);
420
+ }
421
+ }
422
+ async replayDurableEvents(signal) {
423
+ let cursor = this.eventBuffer.cursor;
424
+ let hasMore = true;
425
+ let pages = 0;
426
+ while (hasMore && pages < 100) {
427
+ const page = await this.session.events(cursor, 100, { signal });
428
+ const merged = this.eventBuffer.merge(page.events);
429
+ if (merged.gap) {
430
+ throw new AgentError(
431
+ `Durable event gap: expected ${merged.gap.expected}, received ${merged.gap.received}`,
432
+ 0,
433
+ "event_gap"
434
+ );
435
+ }
436
+ if (page.hasMore && page.cursor <= cursor) {
437
+ throw new AgentError("Durable replay did not advance", 0, "replay_stalled");
438
+ }
439
+ cursor = this.eventBuffer.cursor;
440
+ hasMore = page.hasMore;
441
+ pages += 1;
442
+ }
443
+ if (hasMore) {
444
+ throw new AgentError(
445
+ "Conversation history exceeds the current 10,000-event UI limit",
446
+ 0,
447
+ "history_limit_exceeded"
448
+ );
449
+ }
450
+ }
451
+ async openLiveConnection(generation, signal) {
452
+ const connection = await this.session.connect({
453
+ after: this.eventBuffer.cursor,
454
+ signal,
455
+ onEvent: (event) => this.receiveLiveEvent(event, generation),
456
+ onReplayComplete: (cursor) => {
457
+ if (generation !== this.generation || signal.aborted) return;
458
+ if (cursor > this.eventBuffer.cursor) {
459
+ const active = this.connection;
460
+ this.connection = null;
461
+ active?.close(1012, "repairing event gap");
462
+ this.scheduleReconnect(
463
+ new AgentError("Live replay ended ahead of the local cursor", 0, "event_gap"),
464
+ true
465
+ );
466
+ return;
467
+ }
468
+ this.reconnectAttempt = 0;
469
+ this.setState({ ...this.state, connection: "live", error: null });
470
+ },
471
+ onError: (error) => {
472
+ if (generation === this.generation && !signal.aborted) {
473
+ this.setState({ ...this.state, error });
474
+ }
475
+ },
476
+ onClose: () => {
477
+ if (generation === this.generation && !signal.aborted && this.connection !== null) {
478
+ this.connection = null;
479
+ this.scheduleReconnect(
480
+ new AgentError("Live connection closed", 0, "websocket_closed")
481
+ );
482
+ }
389
483
  }
390
- if (hasMore) throw new Error("Conversation history exceeds the current 10,000-event UI limit");
484
+ });
485
+ if (signal.aborted || generation !== this.generation) {
486
+ connection.close(1e3, "stale connection");
487
+ return;
488
+ }
489
+ this.connection = connection;
490
+ }
491
+ receiveLiveEvent(event, generation) {
492
+ if (generation !== this.generation) return;
493
+ if (event.sessionId !== this.session.id) {
494
+ const active = this.connection;
495
+ this.connection = null;
496
+ active?.close(1008, "wrong session event");
391
497
  this.setState({
392
- status: "ready",
393
- snapshot,
394
- events,
395
- messages: reduceAgentMessages(events),
396
- toolCalls: reduceAgentToolCalls(events),
397
- error: null
498
+ ...this.state,
499
+ status: "error",
500
+ connection: "closed",
501
+ error: new AgentError("Runtime returned an event for another session", 0, "invalid_event")
398
502
  });
399
- this.scheduleRefresh();
400
- } catch (error) {
401
- if (signal.aborted) return;
503
+ return;
504
+ }
505
+ const merged = this.eventBuffer.merge([event]);
506
+ const snapshot = merged.accepted.reduce(
507
+ (current, accepted) => applyEventToSnapshot(current, accepted),
508
+ this.state.snapshot
509
+ );
510
+ this.publishBuffer(snapshot, this.state.connection);
511
+ if (merged.gap) {
512
+ const active = this.connection;
513
+ this.connection = null;
514
+ active?.close(1012, "repairing event gap");
515
+ this.scheduleReconnect(
516
+ new AgentError(
517
+ `Live event gap: expected ${merged.gap.expected}, received ${merged.gap.received}`,
518
+ 0,
519
+ "event_gap"
520
+ ),
521
+ true
522
+ );
523
+ }
524
+ }
525
+ publishBuffer(snapshot, connection) {
526
+ const events = [...this.eventBuffer.events];
527
+ this.setState({
528
+ status: snapshot ? "ready" : this.state.status,
529
+ connection,
530
+ snapshot,
531
+ events,
532
+ messages: reduceAgentMessages(events),
533
+ toolCalls: reduceAgentToolCalls(events),
534
+ error: null
535
+ });
536
+ }
537
+ scheduleReconnect(error, immediate = false) {
538
+ if (this.listeners.size === 0) return;
539
+ const normalized = error instanceof Error ? error : new Error(String(error));
540
+ if (isPermanentConnectionError(normalized)) {
402
541
  this.setState({
403
542
  ...this.state,
404
543
  status: "error",
405
- error: error instanceof Error ? error : new Error(String(error))
544
+ connection: "closed",
545
+ error: normalized
406
546
  });
407
- this.scheduleRefresh();
547
+ return;
408
548
  }
549
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
550
+ const exponent = Math.min(this.reconnectAttempt, 5);
551
+ const delay = immediate ? 0 : Math.round(Math.min(1e4, 400 * 2 ** exponent) * (0.75 + Math.random() * 0.5));
552
+ this.reconnectAttempt += 1;
553
+ this.setState({
554
+ ...this.state,
555
+ status: this.state.snapshot ? "ready" : "error",
556
+ connection: "reconnecting",
557
+ error: this.state.snapshot ? null : normalized
558
+ });
559
+ this.reconnectTimer = setTimeout(() => {
560
+ this.reconnectTimer = null;
561
+ void this.refresh();
562
+ }, delay);
409
563
  }
410
- dispose() {
564
+ stop() {
565
+ this.generation += 1;
411
566
  this.controller?.abort();
412
- if (this.refreshTimer) clearTimeout(this.refreshTimer);
413
- this.refreshTimer = null;
567
+ this.controller = null;
568
+ this.connection?.close(1e3, "no subscribers");
569
+ this.connection = null;
570
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
571
+ this.reconnectTimer = null;
572
+ }
573
+ dispose() {
574
+ this.stop();
414
575
  }
415
576
  };
577
+ function isPermanentConnectionError(error) {
578
+ return error instanceof AgentError && (error.status === 401 || error.status === 403 || error.status === 404);
579
+ }
580
+ function applyEventToSnapshot(snapshot, event) {
581
+ if (!snapshot) return null;
582
+ const status = turnStatusForEvent(event.type);
583
+ if (!status || !event.turnId) {
584
+ return event.id > snapshot.cursor ? { ...snapshot, cursor: event.id } : snapshot;
585
+ }
586
+ const existing = snapshot.turns.find((turn2) => turn2.id === event.turnId);
587
+ const turn = {
588
+ id: event.turnId,
589
+ status,
590
+ attempt: event.attempt,
591
+ createdAt: existing?.createdAt ?? event.createdAt,
592
+ updatedAt: event.createdAt
593
+ };
594
+ return {
595
+ ...snapshot,
596
+ updatedAt: event.createdAt,
597
+ cursor: Math.max(snapshot.cursor, event.id),
598
+ turns: existing ? snapshot.turns.map(
599
+ (candidate) => candidate.id === event.turnId ? turn : candidate
600
+ ) : [...snapshot.turns, turn]
601
+ };
602
+ }
603
+ function turnStatusForEvent(type) {
604
+ if (type === "turn.queued") return "queued";
605
+ if (type === "turn.started") return "running";
606
+ if (type === "turn.completed") return "completed";
607
+ if (type === "turn.failed") return "failed";
608
+ if (type === "turn.cancelled") return "cancelled";
609
+ return null;
610
+ }
416
611
  function useAgentSession(sessionId) {
417
612
  const context = useAgentContext();
418
613
  const store = useMemo(() => {
@@ -712,7 +907,7 @@ function AgentToolCall({ toolCall, className, style, theme, copy }) {
712
907
  const labels = { ...context.copy, ...copy };
713
908
  const [expanded, setExpanded] = useState(false);
714
909
  const [hovered, setHovered] = useState(false);
715
- const hasDetails = toolCall.input !== void 0 || toolCall.output !== void 0;
910
+ const hasDetails = toolCall.input !== void 0 || toolCall.output !== void 0 || toolCall.error !== void 0;
716
911
  const title = toolCall.label === toolCall.name ? humanizeToolName(toolCall.name) : toolCall.label;
717
912
  const summary = toolCall.summary ?? deriveToolSummary(toolCall.input);
718
913
  const statusLabel = toolCall.status === "completed" ? labels.toolCompleted : toolCall.status === "failed" ? labels.toolFailed : toolCall.status === "approval_required" ? labels.toolApprovalRequired : labels.toolRunning;
@@ -793,6 +988,10 @@ function AgentToolCall({ toolCall, className, style, theme, copy }) {
793
988
  toolCall.output !== void 0 ? /* @__PURE__ */ jsxs("div", { children: [
794
989
  /* @__PURE__ */ jsx("div", { style: { marginBottom: 2, color: colors.inkTertiary, fontFamily: colors.fontFamily }, children: "Output" }),
795
990
  /* @__PURE__ */ jsx("pre", { style: { margin: 0, whiteSpace: "pre-wrap", overflowWrap: "anywhere", font: "inherit" }, children: formatToolPayload(toolCall.output) })
991
+ ] }) : null,
992
+ toolCall.error !== void 0 ? /* @__PURE__ */ jsxs("div", { children: [
993
+ /* @__PURE__ */ jsx("div", { style: { marginBottom: 2, color: colors.statusBad, fontFamily: colors.fontFamily }, children: "Error" }),
994
+ /* @__PURE__ */ jsx("pre", { style: { margin: 0, whiteSpace: "pre-wrap", overflowWrap: "anywhere", font: "inherit" }, children: formatToolPayload(toolCall.error) })
796
995
  ] }) : null
797
996
  ]
798
997
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codespring-app/use-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Server and React SDKs for the CodeSpring Agents runtime",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "docs/screenshots",
19
- "README.md"
19
+ "README.md",
20
+ "CHANGELOG.md"
20
21
  ],
21
22
  "exports": {
22
23
  ".": {
@@ -33,6 +34,7 @@
33
34
  "build": "tsup src/index.ts src/react.tsx --format esm --dts --clean --out-dir dist",
34
35
  "showcase": "vite examples/showcase --host 127.0.0.1",
35
36
  "showcase:build": "vite build examples/showcase --outDir ../../dist-showcase --emptyOutDir",
37
+ "example:live": "bun run examples/live-session-replay.ts",
36
38
  "test": "bun test",
37
39
  "typecheck": "tsc -p tsconfig.json --noEmit",
38
40
  "check": "bun run typecheck && bun run test && bun run build"