agents-vue 0.1.0 β†’ 0.2.1

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,41 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## v0.2.1
5
+
6
+ [compare changes](https://github.com/ymansurozer/agents-vue/compare/v0.2.0...v0.2.1)
7
+
8
+ ### 🩹 Fixes
9
+
10
+ - Serialize reconnect resume re-probes (parity with agents 0.17.2, #1837) ([#1837](https://github.com/ymansurozer/agents-vue/issues/1837))
11
+
12
+ ### ❀️ Contributors
13
+
14
+ - Ymansurozer <ymansurozer@gmail.com>
15
+
16
+ ## v0.2.0
17
+
18
+ [compare changes](https://github.com/ymansurozer/agents-vue/compare/v0.1.0...v0.2.0)
19
+
20
+ Brings the Vue port to parity with `@cloudflare/ai-chat` 0.9.x on resume/recovery.
21
+
22
+ ### πŸš€ Enhancements
23
+
24
+ - 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)
25
+ - `isRecovering` reactive flag, driven by the `cf_agent_chat_recovering` frame and distinct from streaming (upstream 0.8.0)
26
+ - `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)
27
+ - `cf_agent_stream_pending` handling: extend the resume probe 5s β†’ 60s during the server's pre-stream window (upstream 0.9.0)
28
+ - `connectionError` surfaced from the `AgentClient` on terminal WebSocket close (upstream 0.9.0)
29
+
30
+ ### 🏑 Chore
31
+
32
+ - Bump `agents` to 0.17.0 and raise the peer floor to `>=0.17.0` (first version exporting `connectionError` / `onConnectionError`)
33
+
34
+ ### ❀️ Contributors
35
+
36
+ - Ymansurozer <ymansurozer@gmail.com>
37
+
38
+
4
39
  ## v0.1.0
5
40
 
6
41
 
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,12 @@ 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) | βœ… |
168
+ | Reconnect resume serialization (in-flight gate, `agents` 0.17.2 / #1837) | βœ… |
160
169
  | `useAgent` connection hook | 🚧 roadmap |
161
170
  | Initial-message HTTP cache | 🚧 roadmap |
162
171
  | `isServerStreaming` / `isStreaming` flags | 🚧 roadmap |
@@ -166,7 +175,7 @@ const transport = new WebSocketChatTransport({
166
175
  ```bash
167
176
  pnpm install
168
177
  pnpm exec playwright install chromium
169
- pnpm test # 59 tests in chromium (vitest-browser-vue)
178
+ pnpm test # 80 tests in chromium (vitest-browser-vue)
170
179
  pnpm typecheck
171
180
  pnpm lint
172
181
  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,24 @@ 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
+ let resumeInFlight = false;
619
+ let resumeGeneration = 0;
620
+ const isRecovering = ref(false);
621
+ const connectionError = ref(null);
622
+ const socket = new AgentClient({
623
+ ...options.client,
624
+ onConnectionError: (error) => {
625
+ connectionError.value = error;
626
+ options.client.onConnectionError?.(error);
627
+ }
628
+ });
485
629
  const transport = new WebSocketChatTransport({
486
630
  socket,
487
631
  activeRequestIds,
632
+ cancelOnClientAbort: options.cancelOnClientAbort ?? false,
488
633
  prepareBody: async () => {
489
634
  const rawBody = options.body;
490
635
  const extraBody = typeof rawBody === "function" ? await rawBody() : rawBody ?? {};
@@ -531,6 +676,7 @@ function useAgentChat(options) {
531
676
  }
532
677
  async function stop() {
533
678
  try {
679
+ transport.cancelActiveServerTurn();
534
680
  await chat.stop();
535
681
  } finally {
536
682
  transport.abortActiveToolContinuation();
@@ -539,6 +685,7 @@ function useAgentChat(options) {
539
685
  function clearHistory() {
540
686
  void stop();
541
687
  isResumingToolContinuation = false;
688
+ isRecovering.value = false;
542
689
  streamState = broadcastTransition(streamState, { type: "clear" }).state;
543
690
  chat.messages = [];
544
691
  socket.send(JSON.stringify({ type: "cf_agent_chat_clear" }));
@@ -619,21 +766,33 @@ function useAgentChat(options) {
619
766
  case "cf_agent_chat_clear":
620
767
  streamState = broadcastTransition(streamState, { type: "clear" }).state;
621
768
  chat.messages = [];
769
+ isRecovering.value = false;
622
770
  break;
623
771
  // Forward resume handshake to transport
624
772
  case "cf_agent_stream_resuming":
625
- if (data.id && !transport.handleStreamResuming({ type: data.type, id: data.id }) && !activeRequestIds.has(data.id)) {
773
+ if (data.id && !transport.handleStreamResuming({ type: data.type, id: data.id }) && !activeRequestIds.has(data.id) && !fallbackAckedResumeRequestIds.has(data.id)) {
626
774
  streamState = broadcastTransition(streamState, {
627
775
  type: "resume-fallback",
628
776
  streamId: data.id,
629
777
  messageId: createMessageId()
630
778
  }).state;
779
+ fallbackAckedResumeRequestIds.add(data.id);
780
+ transport.observeServerTurn(data.id);
781
+ isRecovering.value = false;
631
782
  socket.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id: data.id }));
632
783
  }
633
784
  break;
634
785
  case "cf_agent_stream_resume_none":
635
786
  transport.handleStreamResumeNone();
636
787
  break;
788
+ // Pre-stream window (0.9.0): keep the in-flight resume probe waiting.
789
+ case "cf_agent_stream_pending":
790
+ transport.handleStreamPending();
791
+ break;
792
+ // Durable chat-recovery progress hint (0.8.0). Cleared on terminal/stream below.
793
+ case "cf_agent_chat_recovering":
794
+ isRecovering.value = Boolean(data.recovering);
795
+ break;
637
796
  case "cf_agent_use_chat_response":
638
797
  if (!data.id || activeRequestIds.has(data.id)) {
639
798
  return;
@@ -656,6 +815,10 @@ function useAgentChat(options) {
656
815
  if (result.messagesUpdate) {
657
816
  chat.messages = result.messagesUpdate(chat.messages);
658
817
  }
818
+ if (data.done) {
819
+ isRecovering.value = false;
820
+ transport.handleServerTurnCompleted(data.id);
821
+ }
659
822
  }
660
823
  break;
661
824
  }
@@ -663,12 +826,23 @@ function useAgentChat(options) {
663
826
  }
664
827
  });
665
828
  socket.addEventListener("open", (event) => {
829
+ connectionError.value = null;
666
830
  options.onOpen?.(event);
667
- if (options.resume !== false) {
668
- void chat.resumeStream();
831
+ if (options.resume !== false && !resumeInFlight && !isResumingToolContinuation && !transport.isAwaitingResume()) {
832
+ resumeInFlight = true;
833
+ const myGeneration = resumeGeneration;
834
+ void chat.resumeStream().catch(() => {
835
+ }).finally(() => {
836
+ if (resumeGeneration !== myGeneration) {
837
+ return;
838
+ }
839
+ resumeInFlight = false;
840
+ });
669
841
  }
670
842
  });
671
843
  socket.addEventListener("close", (event) => {
844
+ fallbackAckedResumeRequestIds.clear();
845
+ isRecovering.value = false;
672
846
  if (options.onClose && event instanceof CloseEvent) {
673
847
  options.onClose(event);
674
848
  }
@@ -677,6 +851,10 @@ function useAgentChat(options) {
677
851
  options.onSocketError?.(event);
678
852
  });
679
853
  onScopeDispose(() => {
854
+ fallbackAckedResumeRequestIds.clear();
855
+ isRecovering.value = false;
856
+ resumeGeneration++;
857
+ resumeInFlight = false;
680
858
  socket.close();
681
859
  });
682
860
  return {
@@ -685,7 +863,11 @@ function useAgentChat(options) {
685
863
  clearHistory,
686
864
  chat,
687
865
  socket,
688
- stop
866
+ stop,
867
+ /** Reactive: true while the server is recovering a durable chat turn. */
868
+ isRecovering,
869
+ /** Reactive: terminal WebSocket connection failure, or null. */
870
+ connectionError
689
871
  };
690
872
  }
691
873
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-vue",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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
+ }