convex-durable-agents 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/client/handler.d.ts +1 -1
  2. package/dist/client/handler.d.ts.map +1 -1
  3. package/dist/client/handler.js +137 -134
  4. package/dist/client/handler.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +2 -2
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/agent.d.ts.map +1 -1
  8. package/dist/component/agent.js +22 -3
  9. package/dist/component/agent.js.map +1 -1
  10. package/dist/component/messages.d.ts +7 -0
  11. package/dist/component/messages.d.ts.map +1 -1
  12. package/dist/component/messages.js +7 -1
  13. package/dist/component/messages.js.map +1 -1
  14. package/dist/component/threads.d.ts.map +1 -1
  15. package/dist/component/threads.js +6 -0
  16. package/dist/component/threads.js.map +1 -1
  17. package/dist/component/tool_calls.d.ts +2 -2
  18. package/dist/component/tool_calls.d.ts.map +1 -1
  19. package/dist/component/tool_calls.js +48 -13
  20. package/dist/component/tool_calls.js.map +1 -1
  21. package/dist/react/agent-chat.d.ts.map +1 -1
  22. package/dist/react/agent-chat.js +20 -1
  23. package/dist/react/agent-chat.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/client/handler.ts +148 -148
  26. package/src/component/_generated/component.ts +2 -2
  27. package/src/component/agent.ts +24 -3
  28. package/src/component/messages.ts +17 -3
  29. package/src/component/threads.ts +6 -0
  30. package/src/component/tool_calls.ts +50 -13
  31. package/src/react/agent-chat.ts +22 -1
  32. package/dist/shared/stream_seq.d.ts +0 -2
  33. package/dist/shared/stream_seq.d.ts.map +0 -1
  34. package/dist/shared/stream_seq.js +0 -4
  35. package/dist/shared/stream_seq.js.map +0 -1
@@ -178,7 +178,7 @@ export function streamHandlerAction(
178
178
  });
179
179
  if (stream == null) {
180
180
  logger.debug("Stream lock acquisition failed, exiting handler");
181
- return;
181
+ return null;
182
182
  }
183
183
  logger.debug(`Stream lock acquired (seq=${stream.seq})`);
184
184
  streamer.startHeartbeat();
@@ -212,178 +212,178 @@ export function streamHandlerAction(
212
212
  });
213
213
  }
214
214
 
215
- try {
216
- const uiMessages = messages.map((m) => messageDocToUIMessage(m));
217
- logger.debug(`Converted ${uiMessages.length} UI messages, transforming to model messages...`);
218
- const modelMessages = transformMessages(
219
- await convertToModelMessages(uiMessages, { tools: handlerlessTools }),
220
- );
221
- logger.debug(`Model messages ready (${modelMessages.length} messages), starting streamText...`);
222
- const result = streamText({
223
- ...streamTextArgs,
224
- prompt: undefined,
225
- messages: modelMessages,
226
- tools: handlerlessTools,
227
- });
215
+ const uiMessages = messages.map((m) => messageDocToUIMessage(m));
216
+ logger.debug(`Converted ${uiMessages.length} UI messages, transforming to model messages...`);
217
+ const modelMessages = transformMessages(await convertToModelMessages(uiMessages, { tools: handlerlessTools }));
218
+ logger.debug(`Model messages ready (${modelMessages.length} messages), starting streamText...`);
219
+ const result = streamText({
220
+ ...streamTextArgs,
221
+ prompt: undefined,
222
+ messages: modelMessages,
223
+ tools: handlerlessTools,
224
+ });
228
225
 
229
- let finishReason: string | undefined;
230
- let responseMessage: UIMessage | undefined;
226
+ let finishReason: string | undefined;
227
+ let responseMessage: UIMessage | undefined;
231
228
 
232
- const uiMessageStream = result.toUIMessageStream({
233
- generateMessageId: generateId,
234
- originalMessages: uiMessages,
235
- onFinish: ({ responseMessage: finalResponseMessage }) => {
236
- responseMessage = finalResponseMessage;
237
- },
238
- });
229
+ const uiMessageStream = result.toUIMessageStream({
230
+ generateMessageId: generateId,
231
+ originalMessages: uiMessages,
232
+ onFinish: ({ responseMessage: finalResponseMessage }) => {
233
+ responseMessage = finalResponseMessage;
234
+ },
235
+ });
239
236
 
240
- let msgId: string | undefined;
241
- if (messages.length > 0) {
242
- msgId = messages[messages.length - 1]!.id;
243
- logger.debug(`Setting initial message ID from last message: ${msgId}`);
244
- await streamer.setMessageId(msgId, true);
237
+ let msgId: string | undefined;
238
+ if (messages.length > 0) {
239
+ msgId = messages[messages.length - 1]!.id;
240
+ logger.debug(`Setting initial message ID from last message: ${msgId}`);
241
+ await streamer.setMessageId(msgId, true);
242
+ }
243
+ let toolCallCount = 0;
244
+ logger.debug("Processing UI message stream parts...");
245
+ for await (const part of uiMessageStream) {
246
+ if (part.type === "start") {
247
+ msgId = part.messageId;
248
+ logger.debug(`Stream part: start (messageId=${msgId})`);
249
+ await streamer.setMessageId(
250
+ msgId,
251
+ messages?.some((m) => m.id === msgId),
252
+ );
245
253
  }
246
- let toolCallCount = 0;
247
- logger.debug("Processing UI message stream parts...");
248
- for await (const part of uiMessageStream) {
249
- if (part.type === "start") {
250
- msgId = part.messageId;
251
- logger.debug(`Stream part: start (messageId=${msgId})`);
252
- await streamer.setMessageId(
253
- msgId,
254
- messages?.some((m) => m.id === msgId),
255
- );
256
- }
257
- await streamer.process(part);
254
+ await streamer.process(part);
258
255
 
259
- switch (part.type) {
260
- case "tool-input-available":
261
- toolCallCount++;
262
- logger.debug(
263
- `Stream part: tool-input-available (tool=${part.toolName}, callId=${part.toolCallId}, count=${toolCallCount})`,
264
- );
265
- await scheduleToolCall(
266
- ctx,
267
- {
268
- toolCallId: part.toolCallId,
269
- toolName: part.toolName,
270
- args: part.input,
271
- msgId: msgId,
272
- threadId: args.threadId,
273
- saveDelta: !!saveStreamDeltas,
274
- },
275
- toolDefinitions,
276
- logger,
277
- );
278
- break;
279
- case "finish":
280
- finishReason = part.finishReason;
281
- logger.debug(`Stream part: finish (reason=${finishReason})`);
282
- break;
283
- case "error":
284
- logger.error("Stream error:", part.errorText);
285
- throw new Error(`Stream error: ${part.errorText}`);
286
- default:
287
- // Ignore other part types
288
- break;
289
- }
256
+ switch (part.type) {
257
+ case "tool-input-available":
258
+ toolCallCount++;
259
+ logger.debug(
260
+ `Stream part: tool-input-available (tool=${part.toolName}, callId=${part.toolCallId}, count=${toolCallCount})`,
261
+ );
262
+ await scheduleToolCall(
263
+ ctx,
264
+ {
265
+ toolCallId: part.toolCallId,
266
+ toolName: part.toolName,
267
+ args: part.input,
268
+ msgId: msgId,
269
+ threadId: args.threadId,
270
+ saveDelta: !!saveStreamDeltas,
271
+ },
272
+ toolDefinitions,
273
+ logger,
274
+ );
275
+ break;
276
+ case "finish":
277
+ finishReason = part.finishReason;
278
+ logger.debug(`Stream part: finish (reason=${finishReason})`);
279
+ break;
280
+ case "error":
281
+ logger.error("Stream error:", part.errorText);
282
+ throw new Error(`Stream error: ${part.errorText}`);
283
+ default:
284
+ // Ignore other part types
285
+ break;
290
286
  }
291
- logger.debug(`Stream iteration complete (toolCallCount=${toolCallCount}, finishReason=${finishReason})`);
287
+ }
288
+ logger.debug(`Stream iteration complete (toolCallCount=${toolCallCount}, finishReason=${finishReason})`);
292
289
 
293
- // Finish the delta stream
294
- logger.debug("Finishing delta stream...");
295
- await streamer.finish();
290
+ // Finish the delta stream
291
+ logger.debug("Finishing delta stream...");
292
+ await streamer.finish();
296
293
 
297
- if (!responseMessage) {
298
- throw new Error("No response message");
294
+ if (!responseMessage) {
295
+ throw new Error("No response message");
296
+ }
297
+ logger.debug(`Response message received (role=${responseMessage.role}, parts=${responseMessage.parts.length})`);
298
+
299
+ const providerMetadata = await getStreamTextProviderMetadata(result);
300
+ const usage = await getStreamTextUsage(result, providerMetadata);
301
+ logger.debug(`Usage info: ${usage ? JSON.stringify(usage) : "none"}`);
302
+ if (usage && usageHandlerCallback) {
303
+ logger.debug("Invoking onMessageComplete callback...");
304
+ try {
305
+ await usageHandlerCallback(ctx as ActionCtx, {
306
+ threadId: args.threadId,
307
+ streamId: args.streamId,
308
+ message: responseMessage,
309
+ usage,
310
+ providerMetadata: serializeForConvex(providerMetadata),
311
+ });
312
+ logger.debug("onMessageComplete callback succeeded");
313
+ } catch (e) {
314
+ console.error("endOfTurnCallback callback failed:", e);
299
315
  }
316
+ }
317
+
318
+ // Save the assistant response if we have one
319
+ if (responseMessage.role === "assistant" && responseMessage.parts.length > 0) {
300
320
  logger.debug(
301
- `Response message received (role=${responseMessage.role}, parts=${responseMessage.parts.length})`,
321
+ `Saving assistant response (id=${responseMessage.id}, parts=${responseMessage.parts.length}, seq=${stream.seq})`,
302
322
  );
323
+ await ctx.runMutation(component.messages.add, {
324
+ threadId: args.threadId,
325
+ msg: responseMessage,
326
+ overwrite: true,
327
+ committedSeq: stream.seq,
328
+ });
329
+ logger.debug("Applying tool outcomes after saving response...");
330
+ await ctx.runMutation(component.messages.applyToolOutcomes, {
331
+ threadId: args.threadId,
332
+ });
333
+ }
303
334
 
304
- const providerMetadata = await getStreamTextProviderMetadata(result);
305
- const usage = await getStreamTextUsage(result, providerMetadata);
306
- logger.debug(`Usage info: ${usage ? JSON.stringify(usage) : "none"}`);
307
- if (usage && usageHandlerCallback) {
308
- logger.debug("Invoking onMessageComplete callback...");
335
+ // Handle tool calls
336
+ if (toolCallCount > 0) {
337
+ logger.debug(`Setting thread status to awaiting_tool_results (${toolCallCount} tool calls)`);
338
+ finalStatus = "awaiting_tool_results";
339
+ } else if (finishReason && finishReason !== "tool-calls") {
340
+ logger.debug(`No tool calls, setting thread status to completed (finishReason=${finishReason})`);
341
+ finalStatus = "completed";
342
+ if (turnCompleteHandlerCallback) {
343
+ logger.debug("Invoking onTurnComplete callback...");
309
344
  try {
310
- await usageHandlerCallback(ctx as ActionCtx, {
345
+ await turnCompleteHandlerCallback(ctx as ActionCtx, {
311
346
  threadId: args.threadId,
312
347
  streamId: args.streamId,
313
- message: responseMessage,
314
- usage,
315
348
  providerMetadata: serializeForConvex(providerMetadata),
349
+ finishReason,
316
350
  });
317
- logger.debug("onMessageComplete callback succeeded");
351
+ logger.debug("onTurnComplete callback succeeded");
318
352
  } catch (e) {
319
- console.error("endOfTurnCallback callback failed:", e);
353
+ console.error("turnCompleteHandler callback failed:", e);
320
354
  }
321
355
  }
356
+ } else {
357
+ logger.debug(`Unhandled end state: toolCallCount=${toolCallCount}, finishReason=${finishReason}`);
358
+ }
322
359
 
323
- // Save the assistant response if we have one
324
- if (responseMessage && responseMessage.role === "assistant" && responseMessage.parts.length > 0) {
325
- logger.debug(
326
- `Saving assistant response (id=${responseMessage.id}, parts=${responseMessage.parts.length}, seq=${stream.seq})`,
327
- );
328
- await ctx.runMutation(component.messages.add, {
329
- threadId: args.threadId,
330
- msg: responseMessage,
331
- overwrite: true,
332
- committedSeq: stream.seq,
333
- });
334
- logger.debug("Applying tool outcomes after saving response...");
335
- await ctx.runMutation(component.messages.applyToolOutcomes, {
360
+ logger.debug("Stream handler completed successfully");
361
+ return null;
362
+ } catch (error) {
363
+ const errorMessage = error instanceof Error ? error.message : String(error);
364
+ const normalizedError = errorMessage || "Unknown error";
365
+ logger.error("Error in stream handler:", normalizedError);
366
+ try {
367
+ await streamer.fail(normalizedError);
368
+ } catch (streamAbortError) {
369
+ logger.error(
370
+ `Failed to abort stream after handler error: ${streamAbortError instanceof Error ? streamAbortError.message : String(streamAbortError)}`,
371
+ );
372
+ }
373
+ finalStatus = "failed";
374
+ if (errorHandlerCallback) {
375
+ logger.debug("Invoking onError callback...");
376
+ try {
377
+ await errorHandlerCallback(ctx as ActionCtx, {
336
378
  threadId: args.threadId,
379
+ streamId: args.streamId,
380
+ error: normalizedError,
337
381
  });
382
+ } catch (e) {
383
+ console.error("errorHandler callback failed:", e);
338
384
  }
339
-
340
- // Handle tool calls
341
- if (toolCallCount > 0) {
342
- logger.debug(`Setting thread status to awaiting_tool_results (${toolCallCount} tool calls)`);
343
- finalStatus = "awaiting_tool_results";
344
- } else if (finishReason && finishReason !== "tool-calls") {
345
- logger.debug(`No tool calls, setting thread status to completed (finishReason=${finishReason})`);
346
- finalStatus = "completed";
347
- if (turnCompleteHandlerCallback) {
348
- logger.debug("Invoking onTurnComplete callback...");
349
- try {
350
- await turnCompleteHandlerCallback(ctx as ActionCtx, {
351
- threadId: args.threadId,
352
- streamId: args.streamId,
353
- providerMetadata: serializeForConvex(providerMetadata),
354
- finishReason,
355
- });
356
- logger.debug("onTurnComplete callback succeeded");
357
- } catch (e) {
358
- console.error("turnCompleteHandler callback failed:", e);
359
- }
360
- }
361
- } else {
362
- logger.debug(`Unhandled end state: toolCallCount=${toolCallCount}, finishReason=${finishReason}`);
363
- }
364
- } catch (error) {
365
- logger.error("Error in stream handler:", error instanceof Error ? error.message : String(error));
366
- if (streamer) {
367
- await streamer.fail(error instanceof Error ? error.message : "Unknown error");
368
- }
369
- finalStatus = "failed";
370
- if (errorHandlerCallback) {
371
- logger.debug("Invoking onError callback...");
372
- try {
373
- await errorHandlerCallback(ctx as ActionCtx, {
374
- threadId: args.threadId,
375
- streamId: args.streamId,
376
- error: error instanceof Error ? error.message : "Unknown error",
377
- });
378
- } catch (e) {
379
- console.error("errorHandler callback failed:", e);
380
- }
381
- }
382
- throw error;
383
385
  }
384
-
385
- logger.debug("Stream handler completed successfully");
386
- return null;
386
+ throw error;
387
387
  } finally {
388
388
  logger.debug("Finalizing stream turn and checking for continuation...");
389
389
  const finalizeArgs: {
@@ -424,14 +424,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
424
424
  "mutation",
425
425
  "internal",
426
426
  { error: string; id: string },
427
- null,
427
+ boolean,
428
428
  Name
429
429
  >;
430
430
  setResult: FunctionReference<
431
431
  "mutation",
432
432
  "internal",
433
433
  { id: string; result: any },
434
- null,
434
+ boolean,
435
435
  Name
436
436
  >;
437
437
  setToolCallTimeout: FunctionReference<
@@ -60,11 +60,30 @@ export const continueStream = mutation({
60
60
 
61
61
  // Check stop signal
62
62
  if (thread.stopSignal) {
63
- logger.debug("Stop signal detected, setting status to stopped");
64
- await ctx.runMutation(api.threads.setStatus, {
65
- threadId: args.threadId,
63
+ const previousStatus = thread.status;
64
+ const activeStreamId = thread.activeStream ?? null;
65
+ logger.debug(
66
+ `Stop signal detected, transitioning thread to stopped and clearing active stream=${activeStreamId ?? "none"}`,
67
+ );
68
+ await ctx.db.patch(thread._id, {
66
69
  status: "stopped",
70
+ activeStream: null,
71
+ continue: false,
67
72
  });
73
+ if (thread.onStatusChangeHandle && previousStatus !== "stopped") {
74
+ await ctx.runMutation(thread.onStatusChangeHandle as FunctionHandle<"mutation">, {
75
+ threadId: args.threadId,
76
+ status: "stopped",
77
+ previousStatus,
78
+ });
79
+ }
80
+ if (activeStreamId) {
81
+ const activeStream = await ctx.db.get(activeStreamId);
82
+ if (activeStream) {
83
+ logger.debug(`Cancelling active stream=${activeStreamId} due to stop signal`);
84
+ await cancelStream(ctx, activeStream, "stopSignal");
85
+ }
86
+ }
68
87
  return null;
69
88
  }
70
89
 
@@ -141,6 +160,8 @@ export const tryContinueAllThreads = action({
141
160
  args: {},
142
161
  returns: v.null(),
143
162
  handler: async (ctx) => {
163
+ // Manual/admin recovery entrypoint: intentionally unscheduled by default.
164
+ // Invoke this action after outages/deploy interruptions to re-drive incomplete threads.
144
165
  const threads = await ctx.runQuery(api.threads.listIncomplete);
145
166
  for (const threadId of threads) {
146
167
  await ctx.runMutation(api.agent.continueStream, {
@@ -19,6 +19,20 @@ const vUIMessage = vUIMessageBase.extend({
19
19
  id: v.string(),
20
20
  });
21
21
 
22
+ type ToolInputAvailablePart = {
23
+ toolCallId: string;
24
+ state: "input-available";
25
+ callProviderMetadata?: unknown;
26
+ };
27
+
28
+ function isObjectLike(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null;
30
+ }
31
+
32
+ export function isToolInputAvailablePart(part: unknown): part is ToolInputAvailablePart {
33
+ return isObjectLike(part) && typeof part.toolCallId === "string" && part.state === "input-available";
34
+ }
35
+
22
36
  // Message doc validator for return types
23
37
  export const vMessageDoc = vUIMessage.extend({
24
38
  _id: v.string(),
@@ -102,8 +116,8 @@ export const applyToolOutcomes = mutation({
102
116
  .order("asc")) {
103
117
  let modified = false;
104
118
  const parts: UIMessagePart<any, any>[] = [];
105
- for (const part of message.parts as UIMessagePart<any, any>[]) {
106
- if ("toolCallId" in part && part.state === "input-available") {
119
+ for (const part of message.parts as unknown[]) {
120
+ if (isToolInputAvailablePart(part)) {
107
121
  const toolCall = await ctx.db
108
122
  .query("tool_calls")
109
123
  .withIndex("by_thread_tool_call_id", (q) =>
@@ -119,7 +133,7 @@ export const applyToolOutcomes = mutation({
119
133
  }
120
134
  }
121
135
  }
122
- parts.push(part);
136
+ parts.push(part as UIMessagePart<any, any>);
123
137
  }
124
138
  if (modified) {
125
139
  await ctx.db.patch(message._id, { parts });
@@ -397,6 +397,12 @@ export const remove = mutation({
397
397
  .withIndex("by_thread", (q) => q.eq("threadId", args.threadId))
398
398
  .collect();
399
399
  for (const toolCall of toolCalls) {
400
+ if (toolCall.timeoutFnId) {
401
+ const timeoutFn = await ctx.db.system.get(toolCall.timeoutFnId);
402
+ if (timeoutFn?.state.kind === "pending") {
403
+ await ctx.scheduler.cancel(toolCall.timeoutFnId);
404
+ }
405
+ }
400
406
  await ctx.db.delete(toolCall._id);
401
407
  }
402
408
  // Delete the thread
@@ -176,16 +176,20 @@ export const setResult = mutation({
176
176
  id: v.id("tool_calls"),
177
177
  result: v.any(),
178
178
  },
179
- returns: v.null(),
179
+ returns: v.boolean(),
180
180
  handler: async (ctx, args) => {
181
181
  const toolCall = await ctx.db.get(args.id);
182
182
  if (!toolCall) {
183
183
  throw new Error(`Tool call ${args.id} not found`);
184
184
  }
185
+ if (toolCall.status !== "pending") {
186
+ logger.warn(`setResult: skipping overwrite for callId=${toolCall.toolCallId}, currentStatus=${toolCall.status}`);
187
+ return false;
188
+ }
185
189
  logger.debug(`setResult: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}`);
186
190
  await cleanupTimeoutFn(ctx, toolCall);
187
191
  await ctx.db.patch(args.id, { result: args.result, status: "completed" });
188
- return null;
192
+ return true;
189
193
  },
190
194
  });
191
195
 
@@ -194,16 +198,20 @@ export const setError = mutation({
194
198
  id: v.id("tool_calls"),
195
199
  error: v.string(),
196
200
  },
197
- returns: v.null(),
201
+ returns: v.boolean(),
198
202
  handler: async (ctx, args) => {
199
203
  const toolCall = await ctx.db.get(args.id);
200
204
  if (!toolCall) {
201
205
  throw new Error(`Tool call ${args.id} not found`);
202
206
  }
207
+ if (toolCall.status !== "pending") {
208
+ logger.warn(`setError: skipping overwrite for callId=${toolCall.toolCallId}, currentStatus=${toolCall.status}`);
209
+ return false;
210
+ }
203
211
  logger.debug(`setError: callId=${toolCall.toolCallId}, tool=${toolCall.toolName}, error=${args.error}`);
204
212
  await cleanupTimeoutFn(ctx, toolCall);
205
213
  await ctx.db.patch(args.id, { error: args.error, status: "failed", callbackLastError: args.error });
206
- return null;
214
+ return true;
207
215
  },
208
216
  });
209
217
 
@@ -605,11 +613,32 @@ export const onToolComplete = internalMutation({
605
613
 
606
614
  // Check stop signal
607
615
  if (thread.stopSignal) {
608
- logger.debug("onToolComplete: stop signal detected, setting status to stopped");
609
- await ctx.runMutation(api.threads.setStatus, {
610
- threadId: args.threadId,
616
+ const previousStatus = thread.status;
617
+ const activeStreamId = thread.activeStream ?? null;
618
+ logger.debug(
619
+ `onToolComplete: stop signal detected, transitioning thread to stopped and clearing active stream=${activeStreamId ?? "none"}`,
620
+ );
621
+ await ctx.db.patch(args.threadId, {
611
622
  status: "stopped",
623
+ activeStream: null,
624
+ continue: false,
612
625
  });
626
+ if (thread.onStatusChangeHandle && previousStatus !== "stopped") {
627
+ await ctx.runMutation(thread.onStatusChangeHandle as FunctionHandle<"mutation">, {
628
+ threadId: args.threadId,
629
+ status: "stopped",
630
+ previousStatus,
631
+ });
632
+ }
633
+ if (activeStreamId) {
634
+ const activeStream = await ctx.db.get(activeStreamId);
635
+ if (activeStream && (activeStream.state.kind === "pending" || activeStream.state.kind === "streaming")) {
636
+ await ctx.runMutation(api.streams.abort, {
637
+ streamId: activeStreamId,
638
+ reason: "stopSignal",
639
+ });
640
+ }
641
+ }
613
642
  return null;
614
643
  }
615
644
 
@@ -686,18 +715,22 @@ export const addToolResult = mutation({
686
715
  }
687
716
 
688
717
  const threadId = toolCall.threadId;
689
- // Check if already completed
690
718
  if (toolCall.status !== "pending") {
691
- throw new Error(`Tool call ${args.toolCallId} has already been completed`);
719
+ logger.warn(`addToolResult: ignoring duplicate completion for callId=${args.toolCallId}`);
720
+ return null;
692
721
  }
693
722
 
694
723
  logger.debug(`addToolResult: callId=${args.toolCallId}, tool=${toolCall.toolName}, thread=${threadId}`);
695
724
 
696
725
  // Update the tool call record with the result
697
- await ctx.runMutation(api.tool_calls.setResult, {
726
+ const transitioned = await ctx.runMutation(api.tool_calls.setResult, {
698
727
  id: toolCall._id,
699
728
  result: args.result,
700
729
  });
730
+ if (!transitioned) {
731
+ logger.warn(`addToolResult: skipped duplicate completion race for callId=${args.toolCallId}`);
732
+ return null;
733
+ }
701
734
 
702
735
  if (toolCall.saveDelta) {
703
736
  logger.debug(`addToolResult: inserting tool outcome delta for callId=${args.toolCallId}`);
@@ -745,9 +778,9 @@ export const addToolError = mutation({
745
778
 
746
779
  const threadId = toolCall.threadId;
747
780
 
748
- // Check if already completed
749
781
  if (toolCall.status !== "pending") {
750
- throw new Error(`Tool call ${args.toolCallId} has already been completed`);
782
+ logger.warn(`addToolError: ignoring duplicate completion for callId=${args.toolCallId}`);
783
+ return null;
751
784
  }
752
785
 
753
786
  logger.debug(
@@ -755,10 +788,14 @@ export const addToolError = mutation({
755
788
  );
756
789
 
757
790
  // Update the tool call record with the error
758
- await ctx.runMutation(api.tool_calls.setError, {
791
+ const transitioned = await ctx.runMutation(api.tool_calls.setError, {
759
792
  id: toolCall._id,
760
793
  error: args.error,
761
794
  });
795
+ if (!transitioned) {
796
+ logger.warn(`addToolError: skipped duplicate completion race for callId=${args.toolCallId}`);
797
+ return null;
798
+ }
762
799
 
763
800
  if (toolCall.saveDelta) {
764
801
  logger.debug(`addToolError: inserting tool outcome delta for callId=${args.toolCallId}`);
@@ -1,6 +1,7 @@
1
1
  import { useMutation } from "convex/react";
2
2
  import type { FunctionReference } from "convex/server";
3
3
  import { useCallback } from "react";
4
+ import type { MessageDoc } from "./types";
4
5
  import {
5
6
  type MessagesQuery,
6
7
  type StreamingMessageUpdatesQuery,
@@ -15,6 +16,20 @@ type StopThreadMutation = FunctionReference<"mutation", "public", { threadId: st
15
16
 
16
17
  type ResumeThreadMutation = FunctionReference<"mutation", "public", { threadId: string; prompt?: string }, null>;
17
18
 
19
+ function createOptimisticMessageDoc({ prompt, threadId }: { threadId: string; prompt: string }): MessageDoc {
20
+ const now = Date.now();
21
+ const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
22
+ const id = `optimistic-${suffix}`;
23
+ return {
24
+ _id: id,
25
+ _creationTime: now,
26
+ threadId,
27
+ id,
28
+ role: "user",
29
+ parts: [{ type: "text", text: prompt }],
30
+ };
31
+ }
32
+
18
33
  export type UseAgentChatOptions = {
19
34
  /** Query to get thread status */
20
35
  getThread: ThreadQuery;
@@ -96,7 +111,13 @@ export function useAgentChat(options: UseAgentChatOptions): UseAgentChatReturn {
96
111
  });
97
112
 
98
113
  // Create mutation functions
99
- const sendMessageMutation = useMutation(sendMessageRef);
114
+ const sendMessageMutation = useMutation(sendMessageRef).withOptimisticUpdate((localStore, args) => {
115
+ const currentMessages = localStore.getQuery(listMessages, { threadId: args.threadId }) ?? [];
116
+ localStore.setQuery(listMessages, { threadId: args.threadId }, [
117
+ ...currentMessages,
118
+ createOptimisticMessageDoc(args),
119
+ ]);
120
+ });
100
121
  const stopThreadMutation = useMutation(stopThreadRef);
101
122
  const resumeThreadMutation = useMutation(resumeThreadRef);
102
123
 
@@ -1,2 +0,0 @@
1
- export declare function nextDeltaSequence(lastSequence: number | undefined): number;
2
- //# sourceMappingURL=stream_seq.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"stream_seq.d.ts","sourceRoot":"","sources":["../../src/shared/stream_seq.ts"],"names":[],"mappings":"AAAA,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAE1E"}
@@ -1,4 +0,0 @@
1
- export function nextDeltaSequence(lastSequence) {
2
- return lastSequence === undefined ? 0 : lastSequence + 1;
3
- }
4
- //# sourceMappingURL=stream_seq.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"stream_seq.js","sourceRoot":"","sources":["../../src/shared/stream_seq.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,iBAAiB,CAAC,YAAgC;IAChE,OAAO,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,GAAG,CAAC,CAAC;AAC3D,CAAC"}