agents-vue 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 CHANGED
@@ -1,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## v0.2.0
5
+
6
+ [compare changes](https://github.com/ymansurozer/agents-vue/compare/v0.1.0...v0.2.0)
7
+
8
+ Brings the Vue port to parity with `@cloudflare/ai-chat` 0.9.x on resume/recovery.
9
+
10
+ ### πŸš€ Enhancements
11
+
12
+ - Resume dedupe: fallback-ACK a resume offer at most once per socket, preventing duplicated assistant text on a double resume replay (upstream 0.8.5)
13
+ - `isRecovering` reactive flag, driven by the `cf_agent_chat_recovering` frame and distinct from streaming (upstream 0.8.0)
14
+ - `cancelOnClientAbort` option: generic client aborts are local-only by default; explicit `stop()` always cancels the server turn β€” including resume-fallback-observed and transport-resumed turns (upstream 0.7.0)
15
+ - `cf_agent_stream_pending` handling: extend the resume probe 5s β†’ 60s during the server's pre-stream window (upstream 0.9.0)
16
+ - `connectionError` surfaced from the `AgentClient` on terminal WebSocket close (upstream 0.9.0)
17
+
18
+ ### 🏑 Chore
19
+
20
+ - Bump `agents` to 0.17.0 and raise the peer floor to `>=0.17.0` (first version exporting `connectionError` / `onConnectionError`)
21
+
22
+ ### ❀️ Contributors
23
+
24
+ - Ymansurozer <ymansurozer@gmail.com>
25
+
26
+
4
27
  ## v0.1.0
5
28
 
6
29
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ Vue 3 composable for [Cloudflare Agents](https://github.com/cloudflare/agents)
6
6
 
7
7
  ## Status
8
8
 
9
- v0.1 β€” covers the surface needed for a working chat UI: streaming responses, client-side tools, tool approvals, stream resumption, broadcast handling. Deeper upstream features (the `useAgent` connection hook, initial-message HTTP cache, `isServerStreaming` flag) are on the roadmap.
9
+ Covers the surface needed for a working chat UI β€” streaming responses, client-side tools, tool approvals, stream resumption, broadcast handling β€” at parity with upstream `@cloudflare/ai-chat` 0.9.x on resume/recovery: resume dedupe, `isRecovering`, `cancelOnClientAbort`, pre-stream `cf_agent_stream_pending`, and `connectionError`. Deeper upstream features (the `useAgent` connection hook, initial-message HTTP cache, `isServerStreaming` / `isStreaming` flags) are on the roadmap.
10
10
 
11
11
  ## Install
12
12
 
@@ -21,7 +21,7 @@ Peer dependencies (must also be installed):
21
21
  | `vue` | `^3.3.4` |
22
22
  | `@ai-sdk/vue` | `^3.0.0` |
23
23
  | `ai` | `^6.0.0` |
24
- | `agents` | `>=0.11.0 <1.0.0` |
24
+ | `agents` | `>=0.17.0 <1.0.0` |
25
25
 
26
26
  ## Usage
27
27
 
@@ -78,7 +78,9 @@ Returns:
78
78
  | `addToolOutput` | `function` | Submit a tool result. Sends `cf_agent_tool_result` and triggers continuation if enabled. |
79
79
  | `addToolApprovalResponse` | `function` | Resolve a human-in-the-loop tool approval. Sends `cf_agent_tool_approval` and triggers continuation. |
80
80
  | `clearHistory` | `function` | Clear local state and broadcast `cf_agent_chat_clear` to the server. |
81
- | `stop` | `function` | Abort the active stream and any pending tool continuation. |
81
+ | `stop` | `function` | Abort the active stream and any pending tool continuation. Always cancels the server turn (explicit cancel). |
82
+ | `isRecovering` | `Ref<boolean>` | Reactive β€” `true` while the server is recovering a durable chat turn (distinct from streaming, so a UI can show "recovering…"). Driven by `cf_agent_chat_recovering`. |
83
+ | `connectionError` | `Ref<AgentConnectionError \| null>` | Reactive β€” the terminal WebSocket connection failure, or `null`. Cleared on reconnect. |
82
84
 
83
85
  ### Options
84
86
 
@@ -91,6 +93,7 @@ Returns:
91
93
  | `onToolCall` | `({ toolCall, addToolOutput }) => void \| Promise<void>` | β€” | Custom tool-call handler. Overrides per-tool `execute`. |
92
94
  | `autoContinueAfterToolResult` | `boolean` | `true` | Server resumes the stream after a tool result. |
93
95
  | `resume` | `boolean` | `true` | Resume in-flight streams on socket reconnect. |
96
+ | `cancelOnClientAbort` | `boolean` | `false` | When `true`, a generic client-side abort cancels the server turn; when `false` (default) such aborts are local-only and the server turn keeps running and can resume. Explicit `stop()` always cancels. |
94
97
  | `onOpen` | `(event: Event) => void` | β€” | WebSocket open. |
95
98
  | `onClose` | `(event: CloseEvent) => void` | β€” | WebSocket close. |
96
99
  | `onSocketError` | `(event: Event) => void` | β€” | Connection-level error. |
@@ -157,6 +160,11 @@ const transport = new WebSocketChatTransport({
157
160
  | Partial-hydration replay rebuild | βœ… |
158
161
  | Broadcast handling (multi-tab) | βœ… |
159
162
  | Tool approval continuation merge across broadcasts | βœ… |
163
+ | Resume dedupe (fallback-ACK at most once per socket) | βœ… |
164
+ | `isRecovering` durable-recovery hint (`cf_agent_chat_recovering`) | βœ… |
165
+ | `cancelOnClientAbort` (local-only abort, default) | βœ… |
166
+ | Pre-stream `cf_agent_stream_pending` (extended resume probe) | βœ… |
167
+ | `connectionError` (terminal WebSocket close) | βœ… |
160
168
  | `useAgent` connection hook | 🚧 roadmap |
161
169
  | Initial-message HTTP cache | 🚧 roadmap |
162
170
  | `isServerStreaming` / `isStreaming` flags | 🚧 roadmap |
@@ -166,7 +174,7 @@ const transport = new WebSocketChatTransport({
166
174
  ```bash
167
175
  pnpm install
168
176
  pnpm exec playwright install chromium
169
- pnpm test # 59 tests in chromium (vitest-browser-vue)
177
+ pnpm test # 79 tests in chromium (vitest-browser-vue)
170
178
  pnpm typecheck
171
179
  pnpm lint
172
180
  pnpm build
package/dist/index.d.mts CHANGED
@@ -1,7 +1,8 @@
1
+ import * as vue from 'vue';
1
2
  import * as ai from 'ai';
2
3
  import { UIMessage, Tool, JSONSchema7, ChatTransport, ChatRequestOptions, UIMessageChunk } from 'ai';
3
4
  import { Chat } from '@ai-sdk/vue';
4
- import { AgentClientOptions, AgentClient } from 'agents/client';
5
+ import { AgentClientOptions, AgentClient, AgentConnectionError } from 'agents/client';
5
6
 
6
7
  interface AgentClientTool<Input = unknown, Output = unknown> {
7
8
  description?: Tool["description"];
@@ -37,6 +38,13 @@ interface UseAgentChatOptions<AgentT = unknown, ChatMessage extends UIMessage =
37
38
  autoContinueAfterToolResult?: boolean;
38
39
  /** When true (default), the chat resumes any in-flight stream on socket open. */
39
40
  resume?: boolean;
41
+ /**
42
+ * When true, a generic client-side abort (e.g. the AI SDK aborting the request on
43
+ * teardown) cancels the server turn. When false (default), such aborts are
44
+ * local-only β€” the server turn keeps running and can be resumed. Explicit `stop()`
45
+ * always cancels the server turn regardless. Matches upstream 0.7.0.
46
+ */
47
+ cancelOnClientAbort?: boolean;
40
48
  /** Called when the underlying WebSocket opens (initial connect or reconnect). */
41
49
  onOpen?: (event: Event) => void;
42
50
  /** Called when the underlying WebSocket closes. */
@@ -70,6 +78,10 @@ declare function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage =
70
78
  chat: Chat<ChatMessage>;
71
79
  socket: AgentClient<AgentT, State>;
72
80
  stop: () => Promise<void>;
81
+ /** Reactive: true while the server is recovering a durable chat turn. */
82
+ isRecovering: vue.Ref<boolean, boolean>;
83
+ /** Reactive: terminal WebSocket connection failure, or null. */
84
+ connectionError: vue.Ref<AgentConnectionError | null, AgentConnectionError | null>;
73
85
  };
74
86
 
75
87
  /** Minimal interface a WebSocket must satisfy for the transport. */
@@ -89,6 +101,15 @@ interface WebSocketChatTransportOptions {
89
101
  trigger: "submit-message" | "regenerate-message";
90
102
  messageId?: string;
91
103
  }) => Record<string, unknown> | Promise<Record<string, unknown>>;
104
+ /**
105
+ * Whether a generic client-side abort (e.g. the AI SDK aborting the request
106
+ * signal, or the stream being cancelled on teardown) should cancel the
107
+ * *server* turn. Local-only by default β€” the server turn keeps running and can
108
+ * be resumed/observed. Explicit `stop()` always cancels via
109
+ * `cancelActiveServerTurn()`, regardless of this flag. Matches upstream 0.7.0.
110
+ * @default false
111
+ */
112
+ cancelOnClientAbort?: boolean;
92
113
  }
93
114
  /**
94
115
  * ChatTransport that communicates with a Cloudflare Agent over WebSocket.
@@ -105,11 +126,30 @@ declare class WebSocketChatTransport implements ChatTransport<UIMessage> {
105
126
  _resumeNoneResolver: (() => void) | null;
106
127
  private _expectToolContinuation;
107
128
  private _abortToolContinuation;
129
+ private cancelOnClientAbort;
130
+ private _activeServerTurnId;
131
+ private _cancelAttachedStream;
132
+ private _onStreamPending;
108
133
  constructor(options: WebSocketChatTransportOptions);
109
134
  /** Mark that the next reconnectToStream() should attach to a server-initiated tool continuation. */
110
135
  expectToolContinuation(): void;
111
136
  /** Abort the active client-side tool continuation stream. */
112
137
  abortActiveToolContinuation(): boolean;
138
+ /** Send a server-turn cancel frame for a request id (best-effort). */
139
+ private sendCancelFrame;
140
+ private setActiveServerTurn;
141
+ private clearActiveServerTurn;
142
+ /** Register a server-observed (broadcast / resume-fallback) turn as the cancellable one. */
143
+ observeServerTurn(requestId: string): void;
144
+ /** Mark a server turn complete so it is no longer the cancellable turn. */
145
+ handleServerTurnCompleted(requestId: string): void;
146
+ /**
147
+ * Explicitly cancel the active server turn (and any tool continuation). Unlike a
148
+ * generic client abort, this always signals the server. Drives `useAgentChat.stop()`.
149
+ */
150
+ cancelActiveServerTurn(): boolean;
151
+ /** Called by the composable on cf_agent_stream_pending β€” extend the in-flight resume probe. */
152
+ handleStreamPending(): boolean;
113
153
  /** True when the transport is waiting for a resume handshake. */
114
154
  isAwaitingResume(): boolean;
115
155
  /** Called by the composable when it receives cf_agent_stream_resuming. */
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import * as vue from 'vue';
1
2
  import * as ai from 'ai';
2
3
  import { UIMessage, Tool, JSONSchema7, ChatTransport, ChatRequestOptions, UIMessageChunk } from 'ai';
3
4
  import { Chat } from '@ai-sdk/vue';
4
- import { AgentClientOptions, AgentClient } from 'agents/client';
5
+ import { AgentClientOptions, AgentClient, AgentConnectionError } from 'agents/client';
5
6
 
6
7
  interface AgentClientTool<Input = unknown, Output = unknown> {
7
8
  description?: Tool["description"];
@@ -37,6 +38,13 @@ interface UseAgentChatOptions<AgentT = unknown, ChatMessage extends UIMessage =
37
38
  autoContinueAfterToolResult?: boolean;
38
39
  /** When true (default), the chat resumes any in-flight stream on socket open. */
39
40
  resume?: boolean;
41
+ /**
42
+ * When true, a generic client-side abort (e.g. the AI SDK aborting the request on
43
+ * teardown) cancels the server turn. When false (default), such aborts are
44
+ * local-only β€” the server turn keeps running and can be resumed. Explicit `stop()`
45
+ * always cancels the server turn regardless. Matches upstream 0.7.0.
46
+ */
47
+ cancelOnClientAbort?: boolean;
40
48
  /** Called when the underlying WebSocket opens (initial connect or reconnect). */
41
49
  onOpen?: (event: Event) => void;
42
50
  /** Called when the underlying WebSocket closes. */
@@ -70,6 +78,10 @@ declare function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage =
70
78
  chat: Chat<ChatMessage>;
71
79
  socket: AgentClient<AgentT, State>;
72
80
  stop: () => Promise<void>;
81
+ /** Reactive: true while the server is recovering a durable chat turn. */
82
+ isRecovering: vue.Ref<boolean, boolean>;
83
+ /** Reactive: terminal WebSocket connection failure, or null. */
84
+ connectionError: vue.Ref<AgentConnectionError | null, AgentConnectionError | null>;
73
85
  };
74
86
 
75
87
  /** Minimal interface a WebSocket must satisfy for the transport. */
@@ -89,6 +101,15 @@ interface WebSocketChatTransportOptions {
89
101
  trigger: "submit-message" | "regenerate-message";
90
102
  messageId?: string;
91
103
  }) => Record<string, unknown> | Promise<Record<string, unknown>>;
104
+ /**
105
+ * Whether a generic client-side abort (e.g. the AI SDK aborting the request
106
+ * signal, or the stream being cancelled on teardown) should cancel the
107
+ * *server* turn. Local-only by default β€” the server turn keeps running and can
108
+ * be resumed/observed. Explicit `stop()` always cancels via
109
+ * `cancelActiveServerTurn()`, regardless of this flag. Matches upstream 0.7.0.
110
+ * @default false
111
+ */
112
+ cancelOnClientAbort?: boolean;
92
113
  }
93
114
  /**
94
115
  * ChatTransport that communicates with a Cloudflare Agent over WebSocket.
@@ -105,11 +126,30 @@ declare class WebSocketChatTransport implements ChatTransport<UIMessage> {
105
126
  _resumeNoneResolver: (() => void) | null;
106
127
  private _expectToolContinuation;
107
128
  private _abortToolContinuation;
129
+ private cancelOnClientAbort;
130
+ private _activeServerTurnId;
131
+ private _cancelAttachedStream;
132
+ private _onStreamPending;
108
133
  constructor(options: WebSocketChatTransportOptions);
109
134
  /** Mark that the next reconnectToStream() should attach to a server-initiated tool continuation. */
110
135
  expectToolContinuation(): void;
111
136
  /** Abort the active client-side tool continuation stream. */
112
137
  abortActiveToolContinuation(): boolean;
138
+ /** Send a server-turn cancel frame for a request id (best-effort). */
139
+ private sendCancelFrame;
140
+ private setActiveServerTurn;
141
+ private clearActiveServerTurn;
142
+ /** Register a server-observed (broadcast / resume-fallback) turn as the cancellable one. */
143
+ observeServerTurn(requestId: string): void;
144
+ /** Mark a server turn complete so it is no longer the cancellable turn. */
145
+ handleServerTurnCompleted(requestId: string): void;
146
+ /**
147
+ * Explicitly cancel the active server turn (and any tool continuation). Unlike a
148
+ * generic client abort, this always signals the server. Drives `useAgentChat.stop()`.
149
+ */
150
+ cancelActiveServerTurn(): boolean;
151
+ /** Called by the composable on cf_agent_stream_pending β€” extend the in-flight resume probe. */
152
+ handleStreamPending(): boolean;
113
153
  /** True when the transport is waiting for a resume handshake. */
114
154
  isAwaitingResume(): boolean;
115
155
  /** Called by the composable when it receives cf_agent_stream_resuming. */
package/dist/index.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  import { Chat } from '@ai-sdk/vue';
2
2
  import { broadcastTransition } from 'agents/chat';
3
3
  import { AgentClient } from 'agents/client';
4
- import { onScopeDispose } from 'vue';
4
+ import { ref, onScopeDispose } from 'vue';
5
5
 
6
6
  function randomRequestId() {
7
7
  return Math.random().toString(36).slice(2, 10);
8
8
  }
9
+ const RESUME_PROBE_TIMEOUT_MS = 5e3;
10
+ const RESUME_PENDING_TIMEOUT_MS = 6e4;
9
11
  class WebSocketChatTransport {
10
12
  socket;
11
13
  prepareBody;
@@ -16,10 +18,20 @@ class WebSocketChatTransport {
16
18
  // Tool continuation state
17
19
  _expectToolContinuation = false;
18
20
  _abortToolContinuation = null;
21
+ // Whether a generic client abort should cancel the server turn (default: local-only).
22
+ cancelOnClientAbort;
23
+ // The currently cancellable server turn β€” either locally initiated (sendMessages)
24
+ // or observed via the broadcast/resume-fallback path. `_cancelAttachedStream` tears
25
+ // down the local stream attached to a locally-initiated turn (null for observed turns).
26
+ _activeServerTurnId = null;
27
+ _cancelAttachedStream = null;
28
+ // Pre-stream "pending" extender for the in-flight resume probe (cleared on resolve).
29
+ _onStreamPending = null;
19
30
  constructor(options) {
20
31
  this.socket = options.socket;
21
32
  this.prepareBody = options.prepareBody;
22
33
  this.activeRequestIds = options.activeRequestIds ?? /* @__PURE__ */ new Set();
34
+ this.cancelOnClientAbort = options.cancelOnClientAbort ?? false;
23
35
  }
24
36
  /** Mark that the next reconnectToStream() should attach to a server-initiated tool continuation. */
25
37
  expectToolContinuation() {
@@ -29,6 +41,55 @@ class WebSocketChatTransport {
29
41
  abortActiveToolContinuation() {
30
42
  return this._abortToolContinuation?.() ?? false;
31
43
  }
44
+ /** Send a server-turn cancel frame for a request id (best-effort). */
45
+ sendCancelFrame(id) {
46
+ try {
47
+ this.socket.send(JSON.stringify({ id, type: "cf_agent_chat_request_cancel" }));
48
+ } catch {
49
+ }
50
+ }
51
+ setActiveServerTurn(id, cancelStream) {
52
+ this._activeServerTurnId = id;
53
+ this._cancelAttachedStream = cancelStream;
54
+ }
55
+ clearActiveServerTurn(id) {
56
+ if (this._activeServerTurnId === id) {
57
+ this._activeServerTurnId = null;
58
+ this._cancelAttachedStream = null;
59
+ }
60
+ }
61
+ /** Register a server-observed (broadcast / resume-fallback) turn as the cancellable one. */
62
+ observeServerTurn(requestId) {
63
+ this.setActiveServerTurn(requestId, null);
64
+ }
65
+ /** Mark a server turn complete so it is no longer the cancellable turn. */
66
+ handleServerTurnCompleted(requestId) {
67
+ this.clearActiveServerTurn(requestId);
68
+ }
69
+ /**
70
+ * Explicitly cancel the active server turn (and any tool continuation). Unlike a
71
+ * generic client abort, this always signals the server. Drives `useAgentChat.stop()`.
72
+ */
73
+ cancelActiveServerTurn() {
74
+ const id = this._activeServerTurnId;
75
+ let cancelledRequest = false;
76
+ if (id) {
77
+ this.sendCancelFrame(id);
78
+ this._cancelAttachedStream?.();
79
+ this.clearActiveServerTurn(id);
80
+ cancelledRequest = true;
81
+ }
82
+ const cancelledToolContinuation = this.abortActiveToolContinuation();
83
+ return cancelledRequest || cancelledToolContinuation;
84
+ }
85
+ /** Called by the composable on cf_agent_stream_pending β€” extend the in-flight resume probe. */
86
+ handleStreamPending() {
87
+ if (!this._onStreamPending) {
88
+ return false;
89
+ }
90
+ this._onStreamPending();
91
+ return true;
92
+ }
32
93
  /** True when the transport is waiting for a resume handshake. */
33
94
  isAwaitingResume() {
34
95
  return this._resumeResolver !== null || this._resumeNoneResolver !== null;
@@ -73,11 +134,14 @@ class WebSocketChatTransport {
73
134
  this.activeRequestIds.add(requestId);
74
135
  const socket = this.socket;
75
136
  const activeIds = this.activeRequestIds;
76
- const finish = (action, keepId = false) => {
137
+ const finish = (action, keepId = false, clearServerTurn = true) => {
77
138
  if (completed) {
78
139
  return;
79
140
  }
80
141
  completed = true;
142
+ if (clearServerTurn) {
143
+ this.clearActiveServerTurn(requestId);
144
+ }
81
145
  try {
82
146
  action();
83
147
  } catch {
@@ -89,16 +153,27 @@ class WebSocketChatTransport {
89
153
  };
90
154
  const abortError = new Error("Aborted");
91
155
  abortError.name = "AbortError";
156
+ let requestSent = false;
92
157
  let streamController;
158
+ const cancelActiveRequest = () => {
159
+ if (completed) {
160
+ return false;
161
+ }
162
+ finish(() => streamController.error(abortError), true);
163
+ return true;
164
+ };
93
165
  const onAbort = () => {
94
166
  if (completed) {
95
167
  return;
96
168
  }
97
- try {
98
- socket.send(JSON.stringify({ id: requestId, type: "cf_agent_chat_request_cancel" }));
99
- } catch {
169
+ if (this.cancelOnClientAbort) {
170
+ if (requestSent) {
171
+ this.sendCancelFrame(requestId);
172
+ }
173
+ finish(() => streamController.error(abortError), requestSent);
174
+ } else {
175
+ finish(() => streamController.error(abortError), false, !requestSent);
100
176
  }
101
- finish(() => streamController.error(abortError), true);
102
177
  };
103
178
  const stream = new ReadableStream({
104
179
  start(controller) {
@@ -135,19 +210,23 @@ class WebSocketChatTransport {
135
210
  onAbort();
136
211
  }
137
212
  });
213
+ this.setActiveServerTurn(requestId, cancelActiveRequest);
138
214
  if (options.abortSignal) {
139
215
  options.abortSignal.addEventListener("abort", onAbort, { once: true });
140
216
  if (options.abortSignal.aborted) {
141
217
  onAbort();
142
218
  }
143
219
  }
144
- socket.send(
145
- JSON.stringify({
146
- id: requestId,
147
- init: { method: "POST", body: bodyPayload },
148
- type: "cf_agent_use_chat_request"
149
- })
150
- );
220
+ if (!completed) {
221
+ socket.send(
222
+ JSON.stringify({
223
+ id: requestId,
224
+ init: { method: "POST", body: bodyPayload },
225
+ type: "cf_agent_use_chat_request"
226
+ })
227
+ );
228
+ requestSent = true;
229
+ }
151
230
  return stream;
152
231
  }
153
232
  async reconnectToStream(_options) {
@@ -158,6 +237,7 @@ class WebSocketChatTransport {
158
237
  const activeIds = this.activeRequestIds;
159
238
  return new Promise((resolve) => {
160
239
  let resolved = false;
240
+ let timeout;
161
241
  const done = (value) => {
162
242
  if (resolved) {
163
243
  return;
@@ -165,6 +245,7 @@ class WebSocketChatTransport {
165
245
  resolved = true;
166
246
  this._resumeResolver = null;
167
247
  this._resumeNoneResolver = null;
248
+ this._onStreamPending = null;
168
249
  clearTimeout(timeout);
169
250
  resolve(value);
170
251
  };
@@ -175,11 +256,18 @@ class WebSocketChatTransport {
175
256
  this.socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id }));
176
257
  done(this._createResumeStream(id));
177
258
  };
259
+ this._onStreamPending = () => {
260
+ if (resolved) {
261
+ return;
262
+ }
263
+ clearTimeout(timeout);
264
+ timeout = setTimeout(() => done(null), RESUME_PENDING_TIMEOUT_MS);
265
+ };
178
266
  try {
179
267
  this.socket.send(JSON.stringify({ type: "cf_agent_stream_resume_request" }));
180
268
  } catch {
181
269
  }
182
- const timeout = setTimeout(() => done(null), 5e3);
270
+ timeout = setTimeout(() => done(null), RESUME_PROBE_TIMEOUT_MS);
183
271
  });
184
272
  }
185
273
  /**
@@ -217,6 +305,7 @@ class WebSocketChatTransport {
217
305
  }
218
306
  completed = true;
219
307
  this._abortToolContinuation = null;
308
+ this._onStreamPending = null;
220
309
  clearHandshakeResolvers(resumeResolver, resumeNoneResolver);
221
310
  try {
222
311
  action();
@@ -256,12 +345,26 @@ class WebSocketChatTransport {
256
345
  requestId = data.id;
257
346
  activeIds.add(requestId);
258
347
  clearHandshakeResolvers(onResume, onResumeNone);
348
+ this._onStreamPending = null;
259
349
  clearTimeout(timeout);
260
350
  socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id: requestId }));
261
351
  };
262
352
  onResumeRef = onResume;
263
353
  onResumeNoneRef = onResumeNone;
264
- const timeout = setTimeout(() => finish(() => controller.close(), onResume, onResumeNone), 5e3);
354
+ let timeout = setTimeout(
355
+ () => finish(() => controller.close(), onResume, onResumeNone),
356
+ RESUME_PROBE_TIMEOUT_MS
357
+ );
358
+ this._onStreamPending = () => {
359
+ if (completed) {
360
+ return;
361
+ }
362
+ clearTimeout(timeout);
363
+ timeout = setTimeout(
364
+ () => finish(() => controller.close(), onResume, onResumeNone),
365
+ RESUME_PENDING_TIMEOUT_MS
366
+ );
367
+ };
265
368
  this._resumeResolver = onResume;
266
369
  this._resumeNoneResolver = onResumeNone;
267
370
  const onMessage = (event) => {
@@ -294,9 +397,15 @@ class WebSocketChatTransport {
294
397
  finish(() => controller.close());
295
398
  }
296
399
  },
297
- cancel() {
298
- finish(() => {
299
- });
400
+ cancel: () => {
401
+ if (requestId && this.cancelOnClientAbort) {
402
+ this.sendCancelFrame(requestId);
403
+ finish(() => {
404
+ }, onResumeRef, onResumeNoneRef, true);
405
+ } else {
406
+ finish(() => {
407
+ }, onResumeRef, onResumeNoneRef);
408
+ }
300
409
  }
301
410
  });
302
411
  }
@@ -305,21 +414,30 @@ class WebSocketChatTransport {
305
414
  const socket = this.socket;
306
415
  const activeIds = this.activeRequestIds;
307
416
  const chunkAbort = new AbortController();
417
+ const abortError = new Error("Aborted");
418
+ abortError.name = "AbortError";
308
419
  let completed = false;
309
- const finish = (action) => {
420
+ let streamController;
421
+ const finish = (action, keepId = false, clearServerTurn = true) => {
310
422
  if (completed) {
311
423
  return;
312
424
  }
313
425
  completed = true;
426
+ if (clearServerTurn) {
427
+ this.clearActiveServerTurn(requestId);
428
+ }
314
429
  try {
315
430
  action();
316
431
  } catch {
317
432
  }
318
- activeIds.delete(requestId);
433
+ if (!keepId) {
434
+ activeIds.delete(requestId);
435
+ }
319
436
  chunkAbort.abort();
320
437
  };
321
- return new ReadableStream({
438
+ const stream = new ReadableStream({
322
439
  start(controller) {
440
+ streamController = controller;
323
441
  const onMessage = (event) => {
324
442
  try {
325
443
  const data = JSON.parse(String(event.data));
@@ -348,11 +466,25 @@ class WebSocketChatTransport {
348
466
  };
349
467
  socket.addEventListener("message", onMessage, { signal: chunkAbort.signal });
350
468
  },
351
- cancel() {
352
- finish(() => {
353
- });
469
+ cancel: () => {
470
+ if (this.cancelOnClientAbort) {
471
+ this.sendCancelFrame(requestId);
472
+ finish(() => {
473
+ }, true);
474
+ } else {
475
+ finish(() => {
476
+ }, false, false);
477
+ }
354
478
  }
355
479
  });
480
+ this.setActiveServerTurn(requestId, () => {
481
+ if (completed) {
482
+ return false;
483
+ }
484
+ finish(() => streamController.error(abortError), true);
485
+ return true;
486
+ });
487
+ return stream;
356
488
  }
357
489
  }
358
490
 
@@ -375,6 +507,7 @@ function parseProtocolMessage(data) {
375
507
  replay: typeof parsed.replay === "boolean" ? parsed.replay : void 0,
376
508
  replayComplete: typeof parsed.replayComplete === "boolean" ? parsed.replayComplete : void 0,
377
509
  continuation: typeof parsed.continuation === "boolean" ? parsed.continuation : void 0,
510
+ recovering: typeof parsed.recovering === "boolean" ? parsed.recovering : void 0,
378
511
  message,
379
512
  messages
380
513
  };
@@ -479,12 +612,22 @@ function createMessageId() {
479
612
  function useAgentChat(options) {
480
613
  const autoContinueAfterToolResult = options.autoContinueAfterToolResult ?? true;
481
614
  const activeRequestIds = /* @__PURE__ */ new Set();
615
+ const fallbackAckedResumeRequestIds = /* @__PURE__ */ new Set();
482
616
  let streamState = { status: "idle" };
483
617
  let isResumingToolContinuation = false;
484
- const socket = new AgentClient(options.client);
618
+ const isRecovering = ref(false);
619
+ const connectionError = ref(null);
620
+ const socket = new AgentClient({
621
+ ...options.client,
622
+ onConnectionError: (error) => {
623
+ connectionError.value = error;
624
+ options.client.onConnectionError?.(error);
625
+ }
626
+ });
485
627
  const transport = new WebSocketChatTransport({
486
628
  socket,
487
629
  activeRequestIds,
630
+ cancelOnClientAbort: options.cancelOnClientAbort ?? false,
488
631
  prepareBody: async () => {
489
632
  const rawBody = options.body;
490
633
  const extraBody = typeof rawBody === "function" ? await rawBody() : rawBody ?? {};
@@ -531,6 +674,7 @@ function useAgentChat(options) {
531
674
  }
532
675
  async function stop() {
533
676
  try {
677
+ transport.cancelActiveServerTurn();
534
678
  await chat.stop();
535
679
  } finally {
536
680
  transport.abortActiveToolContinuation();
@@ -539,6 +683,7 @@ function useAgentChat(options) {
539
683
  function clearHistory() {
540
684
  void stop();
541
685
  isResumingToolContinuation = false;
686
+ isRecovering.value = false;
542
687
  streamState = broadcastTransition(streamState, { type: "clear" }).state;
543
688
  chat.messages = [];
544
689
  socket.send(JSON.stringify({ type: "cf_agent_chat_clear" }));
@@ -619,21 +764,33 @@ function useAgentChat(options) {
619
764
  case "cf_agent_chat_clear":
620
765
  streamState = broadcastTransition(streamState, { type: "clear" }).state;
621
766
  chat.messages = [];
767
+ isRecovering.value = false;
622
768
  break;
623
769
  // Forward resume handshake to transport
624
770
  case "cf_agent_stream_resuming":
625
- if (data.id && !transport.handleStreamResuming({ type: data.type, id: data.id }) && !activeRequestIds.has(data.id)) {
771
+ if (data.id && !transport.handleStreamResuming({ type: data.type, id: data.id }) && !activeRequestIds.has(data.id) && !fallbackAckedResumeRequestIds.has(data.id)) {
626
772
  streamState = broadcastTransition(streamState, {
627
773
  type: "resume-fallback",
628
774
  streamId: data.id,
629
775
  messageId: createMessageId()
630
776
  }).state;
777
+ fallbackAckedResumeRequestIds.add(data.id);
778
+ transport.observeServerTurn(data.id);
779
+ isRecovering.value = false;
631
780
  socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id: data.id }));
632
781
  }
633
782
  break;
634
783
  case "cf_agent_stream_resume_none":
635
784
  transport.handleStreamResumeNone();
636
785
  break;
786
+ // Pre-stream window (0.9.0): keep the in-flight resume probe waiting.
787
+ case "cf_agent_stream_pending":
788
+ transport.handleStreamPending();
789
+ break;
790
+ // Durable chat-recovery progress hint (0.8.0). Cleared on terminal/stream below.
791
+ case "cf_agent_chat_recovering":
792
+ isRecovering.value = Boolean(data.recovering);
793
+ break;
637
794
  case "cf_agent_use_chat_response":
638
795
  if (!data.id || activeRequestIds.has(data.id)) {
639
796
  return;
@@ -656,6 +813,10 @@ function useAgentChat(options) {
656
813
  if (result.messagesUpdate) {
657
814
  chat.messages = result.messagesUpdate(chat.messages);
658
815
  }
816
+ if (data.done) {
817
+ isRecovering.value = false;
818
+ transport.handleServerTurnCompleted(data.id);
819
+ }
659
820
  }
660
821
  break;
661
822
  }
@@ -663,12 +824,15 @@ function useAgentChat(options) {
663
824
  }
664
825
  });
665
826
  socket.addEventListener("open", (event) => {
827
+ connectionError.value = null;
666
828
  options.onOpen?.(event);
667
829
  if (options.resume !== false) {
668
830
  void chat.resumeStream();
669
831
  }
670
832
  });
671
833
  socket.addEventListener("close", (event) => {
834
+ fallbackAckedResumeRequestIds.clear();
835
+ isRecovering.value = false;
672
836
  if (options.onClose && event instanceof CloseEvent) {
673
837
  options.onClose(event);
674
838
  }
@@ -677,6 +841,8 @@ function useAgentChat(options) {
677
841
  options.onSocketError?.(event);
678
842
  });
679
843
  onScopeDispose(() => {
844
+ fallbackAckedResumeRequestIds.clear();
845
+ isRecovering.value = false;
680
846
  socket.close();
681
847
  });
682
848
  return {
@@ -685,7 +851,11 @@ function useAgentChat(options) {
685
851
  clearHistory,
686
852
  chat,
687
853
  socket,
688
- stop
854
+ stop,
855
+ /** Reactive: true while the server is recovering a durable chat turn. */
856
+ isRecovering,
857
+ /** Reactive: terminal WebSocket connection failure, or null. */
858
+ connectionError
689
859
  };
690
860
  }
691
861
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-vue",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Vue 3 composable for Cloudflare Agents β€” a port of @cloudflare/ai-chat/react's useAgentChat.",
5
5
  "keywords": [
6
6
  "vue",
@@ -38,9 +38,19 @@
38
38
  "README.md",
39
39
  "CHANGELOG.md"
40
40
  ],
41
+ "scripts": {
42
+ "dev": "unbuild --watch",
43
+ "build": "unbuild",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "lint": "eslint .",
47
+ "lint:fix": "eslint . --fix",
48
+ "typecheck": "vue-tsc --noEmit",
49
+ "release": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && changelogen --release && git push --follow-tags"
50
+ },
41
51
  "peerDependencies": {
42
52
  "@ai-sdk/vue": "^3.0.0",
43
- "agents": ">=0.11.0 <1.0.0",
53
+ "agents": ">=0.17.0 <1.0.0",
44
54
  "ai": "^6.0.0",
45
55
  "vue": "^3.3.4"
46
56
  },
@@ -50,7 +60,7 @@
50
60
  "@types/node": "^22.0.0",
51
61
  "@vitest/browser": "^4.1.5",
52
62
  "@vitest/browser-playwright": "^4.1.5",
53
- "agents": "^0.11.4",
63
+ "agents": "^0.17.0",
54
64
  "ai": "^6.0.0",
55
65
  "changelogen": "^0.6.2",
56
66
  "eslint": "^9.0.0",
@@ -69,14 +79,5 @@
69
79
  "engines": {
70
80
  "node": ">=20"
71
81
  },
72
- "scripts": {
73
- "dev": "unbuild --watch",
74
- "build": "unbuild",
75
- "test": "vitest run",
76
- "test:watch": "vitest",
77
- "lint": "eslint .",
78
- "lint:fix": "eslint . --fix",
79
- "typecheck": "vue-tsc --noEmit",
80
- "release": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && changelogen --release && git push --follow-tags"
81
- }
82
- }
82
+ "packageManager": "pnpm@9.0.0"
83
+ }