agents-vue 0.3.0 → 0.4.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,14 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## v0.4.0
5
+
6
+ [compare changes](https://github.com/ymansurozer/agents-vue/compare/v0.3.0...v0.4.0)
7
+
8
+ ### 🚀 Enhancements
9
+
10
+ - `sendOnlyNewMessages` option: a send carries only the messages after the last one the server is known to hold, naming it as `afterMessageId` in the request body, rather than the whole transcript. An edit, or a bare `sendMessage()` resubmitting after an approval or a tool output, carries the message it rewrote; a regenerate carries the full list. The server must rebuild the full list before it persists or broadcasts the request: when the first message sent is one it stores, its stored messages before that one, then the ones sent; otherwise its whole stored transcript, then the sent messages it does not already store, so a client that missed another's messages does not erase them. The option is off by default, and with it off a request body is unchanged.
11
+
4
12
  ## v0.3.0
5
13
 
6
14
  Rebased onto AI SDK v7 and `agents` 0.27, with upstream's own transport and the four client-side bugs the Vue port carried fixed against the React client.
package/README.md CHANGED
@@ -103,6 +103,7 @@ Returns:
103
103
  | `onStateUpdate` | `(state: State, source: "server" \| "client") => void` | — | The agent's state, on connect and on every change the server broadcasts. |
104
104
  | `onFrame` | `(frame: unknown) => boolean \| void` | — | Every socket frame, parsed, **before** the chat protocol reads it. Return `true` to consume it. |
105
105
  | `mergeMessages` | `(onScreen, stored) => ChatMessage[]` | merge by id | How a reconnect's read of the stored transcript joins the one on screen. |
106
+ | `sendOnlyNewMessages` | `boolean` | `false` | A send carries only the messages after the last one the server is known to hold, named as `afterMessageId`. The server must rebuild the rest. |
106
107
 
107
108
  ### `onFrame` — your own frames
108
109
 
@@ -153,6 +154,45 @@ useAgentChat({
153
154
  });
154
155
  ```
155
156
 
157
+ ### `sendOnlyNewMessages` — sending what the server does not have
158
+
159
+ By default every send carries the whole transcript, so a request grows with the session. With
160
+ `sendOnlyNewMessages: true` it carries only the messages after the last one the server is known to
161
+ hold — usually just the new question — and names that message as `afterMessageId`, beside
162
+ `messages` in the request body. Known to hold means seen arriving from the server: `initialMessages`,
163
+ the `get-messages` read, a broadcast, a `cf_agent_message_updated` frame, or a stream that finished
164
+ without an error with a part in its reply. A message only this client has never counts. A request
165
+ that rewrites a message in place carries it too: an edited question, or the assistant message a
166
+ bare `sendMessage()` resubmits after an approval or a tool output. A regenerate, and a send before
167
+ the server is known to hold anything, carry the full list and no `afterMessageId`.
168
+
169
+ A client tool's output is not part of any request: it reaches the server in a `cf_agent_tool_result`
170
+ frame, with the option on or off. What changes is the next request. A question typed after a tool
171
+ output carries only the question, where the full list re-sent the output along with everything
172
+ else; only a request a bare `sendMessage()` makes carries the rewritten assistant message.
173
+
174
+ **The server's half.** A request with `afterMessageId` holds only part of the transcript, so the
175
+ server must rebuild the full list before it persists the request or broadcasts it — `AIChatAgent`
176
+ broadcasts a request's list to the other clients as it arrived. When the first message sent is one
177
+ it already stores, at index `k`, the request rewrites in place, and the full list is its stored
178
+ messages before `k`, then the ones sent. Otherwise the messages sent are new and go after the whole
179
+ stored transcript, less any it already stores, so a client that missed messages another client
180
+ added does not erase them. The rebuild reads `afterMessageId` only to know the list is partial: its
181
+ value, the last message the client saw the server hold, is a hint, and can name a reply the server
182
+ never stored. A request without the field carries the full list, as before. `AIChatAgent` does none
183
+ of this on its own: it takes a request's list for the whole transcript and deletes the stored
184
+ messages missing from it, which is why the option is off by default.
185
+
186
+ ```ts
187
+ function rebuild(stored: UIMessage[], sent: UIMessage[], afterMessageId?: string): UIMessage[] {
188
+ if (afterMessageId === undefined) return sent;
189
+ const rewriteAt = stored.findIndex((message) => message.id === sent[0]?.id);
190
+ if (rewriteAt >= 0) return [...stored.slice(0, rewriteAt), ...sent];
191
+ const storedIds = new Set(stored.map((message) => message.id));
192
+ return [...stored, ...sent.filter((message) => !storedIds.has(message.id))];
193
+ }
194
+ ```
195
+
156
196
  ### Client-side tools
157
197
 
158
198
  ```ts
package/dist/index.d.mts CHANGED
@@ -73,6 +73,25 @@ interface UseAgentChatOptions<AgentT = unknown, ChatMessage extends UIMessage =
73
73
  * differently — which copy of a message is fresher, say.
74
74
  */
75
75
  mergeMessages?: (onScreen: ChatMessage[], stored: ChatMessage[]) => ChatMessage[];
76
+ /**
77
+ * When true, a send carries only the messages after the last one the server is known to hold, and
78
+ * names that message in the request body as `afterMessageId`. Known to hold means seen arriving
79
+ * from the server — in `initialMessages`, the `get-messages` read, a `cf_agent_chat_messages`
80
+ * broadcast, a `cf_agent_message_updated` frame, or a stream that finished without an error with
81
+ * a part in its reply — never a message only this client has.
82
+ *
83
+ * A request that rewrites a message in place carries it too: an edited question, or the assistant
84
+ * message a bare `sendMessage()` resubmits after an approval or a tool output. A tool output itself
85
+ * travels in its own frame, so a question typed after one no longer carries it. A regenerate, and a
86
+ * send before the server is known to hold anything, carry the full list and no `afterMessageId`.
87
+ *
88
+ * Turn it on only against a server that rebuilds the transcript before it persists or broadcasts
89
+ * the request: when the first message sent is one it stores, its stored messages before that one,
90
+ * then the ones sent; otherwise its whole stored transcript, then the sent messages it does not
91
+ * already store, so a client that missed another's messages does not erase them. `AIChatAgent` on
92
+ * its own takes a request's list for the whole transcript, and would keep only the messages sent.
93
+ */
94
+ sendOnlyNewMessages?: boolean;
76
95
  /**
77
96
  * Called with the agent's state, on connect and on every change the server broadcasts.
78
97
  *
package/dist/index.d.ts CHANGED
@@ -73,6 +73,25 @@ interface UseAgentChatOptions<AgentT = unknown, ChatMessage extends UIMessage =
73
73
  * differently — which copy of a message is fresher, say.
74
74
  */
75
75
  mergeMessages?: (onScreen: ChatMessage[], stored: ChatMessage[]) => ChatMessage[];
76
+ /**
77
+ * When true, a send carries only the messages after the last one the server is known to hold, and
78
+ * names that message in the request body as `afterMessageId`. Known to hold means seen arriving
79
+ * from the server — in `initialMessages`, the `get-messages` read, a `cf_agent_chat_messages`
80
+ * broadcast, a `cf_agent_message_updated` frame, or a stream that finished without an error with
81
+ * a part in its reply — never a message only this client has.
82
+ *
83
+ * A request that rewrites a message in place carries it too: an edited question, or the assistant
84
+ * message a bare `sendMessage()` resubmits after an approval or a tool output. A tool output itself
85
+ * travels in its own frame, so a question typed after one no longer carries it. A regenerate, and a
86
+ * send before the server is known to hold anything, carry the full list and no `afterMessageId`.
87
+ *
88
+ * Turn it on only against a server that rebuilds the transcript before it persists or broadcasts
89
+ * the request: when the first message sent is one it stores, its stored messages before that one,
90
+ * then the ones sent; otherwise its whole stored transcript, then the sent messages it does not
91
+ * already store, so a client that missed another's messages does not erase them. `AIChatAgent` on
92
+ * its own takes a request's list for the whole transcript, and would keep only the messages sent.
93
+ */
94
+ sendOnlyNewMessages?: boolean;
76
95
  /**
77
96
  * Called with the agent's state, on connect and on every change the server broadcasts.
78
97
  *
package/dist/index.mjs CHANGED
@@ -128,6 +128,17 @@ function mergeById(onScreen, stored) {
128
128
  const storedIds = new Set(stored.map((message) => message.id));
129
129
  return [...stored, ...onScreen.filter((message) => !storedIds.has(message.id))];
130
130
  }
131
+ function idsOf(messages) {
132
+ return messages.map((message) => message.id);
133
+ }
134
+ function startMessageIdOf(body) {
135
+ try {
136
+ const chunk = JSON.parse(body);
137
+ return isRecord(chunk) && chunk.type === "start" && typeof chunk.messageId === "string" ? chunk.messageId : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
131
142
  function createMessageId() {
132
143
  if (globalThis.crypto?.randomUUID) {
133
144
  return globalThis.crypto.randomUUID();
@@ -146,6 +157,8 @@ function useAgentChat(options) {
146
157
  let continuationLaunchTimer = null;
147
158
  let observedToolContinuationRequestId = null;
148
159
  const dispatchedToolCallIds = /* @__PURE__ */ new Set();
160
+ let heldMessageIds = idsOf(options.initialMessages ?? []);
161
+ const streamMessageIds = /* @__PURE__ */ new Map();
149
162
  let resumeInFlight = false;
150
163
  let resumeGeneration = 0;
151
164
  const isRecovering = ref(false);
@@ -198,9 +211,50 @@ function useAgentChat(options) {
198
211
  }
199
212
  const chat = new Chat({
200
213
  messages: options.initialMessages,
201
- transport,
214
+ transport: options.sendOnlyNewMessages ? {
215
+ sendMessages: (request) => transport.sendMessages(onlyNewMessages(request)),
216
+ reconnectToStream: (request) => transport.reconnectToStream(request)
217
+ } : transport,
202
218
  onError: options.onError
203
219
  });
220
+ function onlyNewMessages(request) {
221
+ if (request.trigger === "regenerate-message") {
222
+ return request;
223
+ }
224
+ const { messages } = request;
225
+ let held = 0;
226
+ while (held < messages.length && messages[held]?.id === heldMessageIds[held]) {
227
+ held++;
228
+ }
229
+ const rewrittenIndex = messages.findIndex((message) => message.id === request.messageId);
230
+ const start = rewrittenIndex >= 0 ? Math.min(held, rewrittenIndex) : held;
231
+ if (rewrittenIndex >= 0) {
232
+ heldMessageIds = heldMessageIds.slice(0, start);
233
+ }
234
+ const afterMessageId = messages[start - 1]?.id;
235
+ if (afterMessageId === void 0 || start === messages.length) {
236
+ return request;
237
+ }
238
+ return { ...request, messages: messages.slice(start), body: { ...request.body, afterMessageId } };
239
+ }
240
+ function holdThrough(messageId) {
241
+ const index = chat.messages.findIndex((message) => message.id === messageId);
242
+ if (index >= 0) {
243
+ heldMessageIds = idsOf(chat.messages.slice(0, index + 1));
244
+ }
245
+ }
246
+ function trackStreamMessage(requestId, frame) {
247
+ if (frame.done || frame.error) {
248
+ const messageId = streamMessageIds.get(requestId);
249
+ streamMessageIds.delete(requestId);
250
+ const reply = chat.messages.find((message) => message.id === messageId);
251
+ if (reply && reply.parts.length > 0 && !frame.error) {
252
+ holdThrough(reply.id);
253
+ }
254
+ } else if (!streamMessageIds.has(requestId) && frame.body?.trim()) {
255
+ streamMessageIds.set(requestId, startMessageIdOf(frame.body));
256
+ }
257
+ }
204
258
  function dispatchRenderedToolCalls() {
205
259
  if (chat.status === "streaming" || chat.status === "submitted" || isServerStreaming.value || isRecovering.value) {
206
260
  return;
@@ -273,6 +327,7 @@ function useAgentChat(options) {
273
327
  isServerStreaming.value = false;
274
328
  streamState = broadcastTransition(streamState, { type: "clear" }).state;
275
329
  chat.messages = [];
330
+ heldMessageIds = [];
276
331
  socket.send(JSON.stringify({ type: "cf_agent_chat_clear" }));
277
332
  }
278
333
  async function addToolOutputForTool(toolName, toolOutput) {
@@ -348,17 +403,20 @@ function useAgentChat(options) {
348
403
  // Full message list sync (sent on connect, and after every appendMessage / turn).
349
404
  case "cf_agent_chat_messages":
350
405
  chat.messages = chat.status === "streaming" || chat.status === "submitted" || streamState.status !== "idle" ? preserveActiveStreamMessages(chat.messages, data.messages ?? []) : data.messages ?? [];
406
+ heldMessageIds = idsOf(data.messages ?? []);
351
407
  break;
352
408
  // Single message update (e.g. tool result applied)
353
409
  case "cf_agent_message_updated":
354
410
  if (data.message) {
355
411
  chat.messages = replaceUpdatedMessage(chat.messages, data.message);
412
+ holdThrough(data.message.id);
356
413
  }
357
414
  break;
358
415
  // Clear all messages
359
416
  case "cf_agent_chat_clear":
360
417
  streamState = broadcastTransition(streamState, { type: "clear" }).state;
361
418
  chat.messages = [];
419
+ heldMessageIds = [];
362
420
  isRecovering.value = false;
363
421
  isServerStreaming.value = false;
364
422
  break;
@@ -405,6 +463,9 @@ function useAgentChat(options) {
405
463
  isRecovering.value = Boolean(data.recovering);
406
464
  break;
407
465
  case "cf_agent_use_chat_response":
466
+ if (data.id) {
467
+ trackStreamMessage(data.id, data);
468
+ }
408
469
  if (!data.id || activeRequestIds.has(data.id)) {
409
470
  return;
410
471
  }
@@ -470,6 +531,7 @@ function useAgentChat(options) {
470
531
  });
471
532
  socket.addEventListener("close", (event) => {
472
533
  fallbackAckedResumeRequestIds.clear();
534
+ streamMessageIds.clear();
473
535
  isRecovering.value = false;
474
536
  connectionState.value = "reconnecting";
475
537
  resyncPending = true;
@@ -521,6 +583,7 @@ function useAgentChat(options) {
521
583
  const stored = await fetchStoredMessages();
522
584
  if (stored && chat.messages.length === 0) {
523
585
  chat.messages = stored;
586
+ heldMessageIds = idsOf(stored);
524
587
  }
525
588
  } finally {
526
589
  initialMessagesLoaded.value = true;
@@ -530,6 +593,7 @@ function useAgentChat(options) {
530
593
  const stored = await fetchStoredMessages();
531
594
  if (stored) {
532
595
  chat.messages = (options.mergeMessages ?? mergeById)(chat.messages, stored);
596
+ heldMessageIds = idsOf(stored);
533
597
  }
534
598
  }
535
599
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-vue",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",