@convex-dev/agent 0.1.4-alpha.0 → 0.1.4

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 (71) hide show
  1. package/dist/commonjs/client/streaming.d.ts +2 -2
  2. package/dist/commonjs/client/streaming.d.ts.map +1 -1
  3. package/dist/commonjs/client/streaming.js +2 -1
  4. package/dist/commonjs/client/streaming.js.map +1 -1
  5. package/dist/commonjs/component/streams.d.ts.map +1 -1
  6. package/dist/commonjs/component/streams.js +1 -4
  7. package/dist/commonjs/component/streams.js.map +1 -1
  8. package/dist/commonjs/react/deltas.d.ts +29 -0
  9. package/dist/commonjs/react/deltas.d.ts.map +1 -0
  10. package/dist/commonjs/react/deltas.js +267 -0
  11. package/dist/commonjs/react/deltas.js.map +1 -0
  12. package/dist/commonjs/react/index.d.ts +4 -49
  13. package/dist/commonjs/react/index.d.ts.map +1 -1
  14. package/dist/commonjs/react/index.js +15 -446
  15. package/dist/commonjs/react/index.js.map +1 -1
  16. package/dist/commonjs/react/optimisticallySendMessage.d.ts +8 -0
  17. package/dist/commonjs/react/optimisticallySendMessage.d.ts.map +1 -0
  18. package/dist/commonjs/react/optimisticallySendMessage.js +40 -0
  19. package/dist/commonjs/react/optimisticallySendMessage.js.map +1 -0
  20. package/dist/commonjs/react/toUIMessages.d.ts.map +1 -1
  21. package/dist/commonjs/react/toUIMessages.js +4 -2
  22. package/dist/commonjs/react/toUIMessages.js.map +1 -1
  23. package/dist/commonjs/react/types.d.ts +26 -0
  24. package/dist/commonjs/react/types.d.ts.map +1 -0
  25. package/dist/commonjs/react/types.js +2 -0
  26. package/dist/commonjs/react/types.js.map +1 -0
  27. package/dist/commonjs/react/useSmoothText.d.ts +20 -0
  28. package/dist/commonjs/react/useSmoothText.d.ts.map +1 -0
  29. package/dist/commonjs/react/useSmoothText.js +50 -0
  30. package/dist/commonjs/react/useSmoothText.js.map +1 -0
  31. package/dist/esm/client/streaming.d.ts +2 -2
  32. package/dist/esm/client/streaming.d.ts.map +1 -1
  33. package/dist/esm/client/streaming.js +2 -1
  34. package/dist/esm/client/streaming.js.map +1 -1
  35. package/dist/esm/component/streams.d.ts.map +1 -1
  36. package/dist/esm/component/streams.js +1 -4
  37. package/dist/esm/component/streams.js.map +1 -1
  38. package/dist/esm/react/deltas.d.ts +29 -0
  39. package/dist/esm/react/deltas.d.ts.map +1 -0
  40. package/dist/esm/react/deltas.js +267 -0
  41. package/dist/esm/react/deltas.js.map +1 -0
  42. package/dist/esm/react/index.d.ts +4 -49
  43. package/dist/esm/react/index.d.ts.map +1 -1
  44. package/dist/esm/react/index.js +15 -446
  45. package/dist/esm/react/index.js.map +1 -1
  46. package/dist/esm/react/optimisticallySendMessage.d.ts +8 -0
  47. package/dist/esm/react/optimisticallySendMessage.d.ts.map +1 -0
  48. package/dist/esm/react/optimisticallySendMessage.js +40 -0
  49. package/dist/esm/react/optimisticallySendMessage.js.map +1 -0
  50. package/dist/esm/react/toUIMessages.d.ts.map +1 -1
  51. package/dist/esm/react/toUIMessages.js +4 -2
  52. package/dist/esm/react/toUIMessages.js.map +1 -1
  53. package/dist/esm/react/types.d.ts +26 -0
  54. package/dist/esm/react/types.d.ts.map +1 -0
  55. package/dist/esm/react/types.js +2 -0
  56. package/dist/esm/react/types.js.map +1 -0
  57. package/dist/esm/react/useSmoothText.d.ts +20 -0
  58. package/dist/esm/react/useSmoothText.d.ts.map +1 -0
  59. package/dist/esm/react/useSmoothText.js +50 -0
  60. package/dist/esm/react/useSmoothText.js.map +1 -0
  61. package/package.json +4 -1
  62. package/src/client/streaming.ts +3 -2
  63. package/src/component/streams.ts +3 -4
  64. package/src/react/deltas.test.ts +298 -0
  65. package/src/react/deltas.ts +340 -0
  66. package/src/react/index.ts +26 -589
  67. package/src/react/optimisticallySendMessage.ts +46 -0
  68. package/src/react/toUIMessages.test.ts +301 -0
  69. package/src/react/toUIMessages.ts +6 -2
  70. package/src/react/types.ts +52 -0
  71. package/src/react/useSmoothText.ts +76 -0
@@ -0,0 +1,26 @@
1
+ import type { BetterOmit, Expand } from "convex-helpers";
2
+ import type { FunctionArgs, FunctionReference, PaginationOptions, PaginationResult } from "convex/server";
3
+ import type { MessageDoc } from "../client";
4
+ import type { SyncStreamsReturnValue } from "../client/types";
5
+ import type { StreamArgs } from "../validators";
6
+ export type ThreadQuery<Args = unknown, M extends MessageDoc = MessageDoc> = FunctionReference<"query", "public", {
7
+ threadId: string;
8
+ paginationOpts: PaginationOptions;
9
+ /**
10
+ * If { stream: true } is passed, it will also query for stream deltas.
11
+ * In order for this to work, the query must take as an argument streamArgs.
12
+ */
13
+ streamArgs?: StreamArgs;
14
+ } & Args, PaginationResult<M> & {
15
+ streams?: SyncStreamsReturnValue;
16
+ }>;
17
+ export type ThreadStreamQuery<Args = Record<string, unknown>, M extends MessageDoc = MessageDoc> = FunctionReference<"query", "public", {
18
+ threadId: string;
19
+ paginationOpts: PaginationOptions;
20
+ streamArgs?: StreamArgs;
21
+ } & Args, PaginationResult<M> & {
22
+ streams: SyncStreamsReturnValue;
23
+ }>;
24
+ export type ThreadMessagesArgs<Query extends ThreadQuery<unknown, MessageDoc>> = Query extends ThreadQuery<unknown, MessageDoc> ? Expand<BetterOmit<FunctionArgs<Query>, "paginationOpts" | "streamArgs">> : never;
25
+ export type ThreadMessagesResult<Query extends ThreadQuery<unknown, MessageDoc>> = Query extends ThreadQuery<unknown, infer M> ? M : never;
26
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/react/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,MAAM,WAAW,CACrB,IAAI,GAAG,OAAO,EACd,CAAC,SAAS,UAAU,GAAG,UAAU,IAC/B,iBAAiB,CACnB,OAAO,EACP,QAAQ,EACR;IACE,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,iBAAiB,CAAC;IAElC;;;OAGG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB,GAAG,IAAI,EACR,gBAAgB,CAAC,CAAC,CAAC,GAAG;IAAE,OAAO,CAAC,EAAE,sBAAsB,CAAA;CAAE,CAC3D,CAAC;AAEF,MAAM,MAAM,iBAAiB,CAC3B,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,CAAC,SAAS,UAAU,GAAG,UAAU,IAC/B,iBAAiB,CACnB,OAAO,EACP,QAAQ,EACR;IACE,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,iBAAiB,CAAC;IAClC,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB,GAAG,IAAI,EACR,gBAAgB,CAAC,CAAC,CAAC,GAAG;IAAE,OAAO,EAAE,sBAAsB,CAAA;CAAE,CAC1D,CAAC;AAEF,MAAM,MAAM,kBAAkB,CAAC,KAAK,SAAS,WAAW,CAAC,OAAO,EAAE,UAAU,CAAC,IAC3E,KAAK,SAAS,WAAW,CAAC,OAAO,EAAE,UAAU,CAAC,GAC1C,MAAM,CAAC,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,gBAAgB,GAAG,YAAY,CAAC,CAAC,GACxE,KAAK,CAAC;AAEZ,MAAM,MAAM,oBAAoB,CAC9B,KAAK,SAAS,WAAW,CAAC,OAAO,EAAE,UAAU,CAAC,IAC5C,KAAK,SAAS,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/react/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A hook that smoothly displays text as it is streamed.
3
+ *
4
+ * @param text The text to display. Pass in the full text each time.
5
+ * @param charsPerSec The number of characters to display per second.
6
+ * @returns A tuple of the visible text and the state of the smooth text,
7
+ * including the current cursor position and whether it's still streaming.
8
+ * This allows you to decide if it's too far behind and you want to adjust
9
+ * the charsPerSec or just prefer the full text.
10
+ */
11
+ export declare function useSmoothText(text: string, { charsPerSec, }?: {
12
+ /**
13
+ * The number of characters to display per second.
14
+ */
15
+ charsPerSec?: number;
16
+ }): [string, {
17
+ cursor: number;
18
+ isStreaming: boolean;
19
+ }];
20
+ //# sourceMappingURL=useSmoothText.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSmoothText.d.ts","sourceRoot":"","sources":["../../../src/react/useSmoothText.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,EACE,WAAiB,GAClB,GAAE;IACD;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACjB,GACL,CAAC,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,CAAC,CAkDpD"}
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ const FPS = 20;
3
+ const MS_PER_FRAME = 1000 / FPS;
4
+ const MAX_TIME_JUMP_MS = 250;
5
+ /**
6
+ * A hook that smoothly displays text as it is streamed.
7
+ *
8
+ * @param text The text to display. Pass in the full text each time.
9
+ * @param charsPerSec The number of characters to display per second.
10
+ * @returns A tuple of the visible text and the state of the smooth text,
11
+ * including the current cursor position and whether it's still streaming.
12
+ * This allows you to decide if it's too far behind and you want to adjust
13
+ * the charsPerSec or just prefer the full text.
14
+ */
15
+ export function useSmoothText(text, { charsPerSec = 256, } = {}) {
16
+ const [visibleText, setVisibleText] = useState(text);
17
+ const smoothState = useRef({
18
+ tick: Date.now() + (text.length * 1000) / charsPerSec,
19
+ cursor: text.length,
20
+ start: Date.now(),
21
+ initialLength: text.length,
22
+ charsPerMs: charsPerSec / 1000,
23
+ });
24
+ const isStreaming = smoothState.current.cursor < text.length;
25
+ useEffect(() => {
26
+ if (!isStreaming) {
27
+ return;
28
+ }
29
+ const latestCharsPerMs = (text.length - smoothState.current.initialLength) /
30
+ (Date.now() - smoothState.current.start);
31
+ // Smooth out the charsPerSec by averaging it with the previous value.
32
+ smoothState.current.charsPerMs = Math.min((2 * latestCharsPerMs + smoothState.current.charsPerMs) / 3, smoothState.current.charsPerMs * 2);
33
+ function update() {
34
+ if (smoothState.current.cursor >= text.length) {
35
+ return;
36
+ }
37
+ const now = Date.now();
38
+ const timeSinceLastUpdate = Math.min(MAX_TIME_JUMP_MS, now - smoothState.current.tick);
39
+ const chars = Math.floor(timeSinceLastUpdate * smoothState.current.charsPerMs);
40
+ smoothState.current.cursor = Math.min(smoothState.current.cursor + chars, text.length);
41
+ smoothState.current.tick = now;
42
+ setVisibleText(text.slice(0, smoothState.current.cursor));
43
+ }
44
+ update();
45
+ const interval = setInterval(update, MS_PER_FRAME);
46
+ return () => clearInterval(interval);
47
+ }, [text, isStreaming, charsPerSec]);
48
+ return [visibleText, { cursor: smoothState.current.cursor, isStreaming }];
49
+ }
50
+ //# sourceMappingURL=useSmoothText.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSmoothText.js","sourceRoot":"","sources":["../../../src/react/useSmoothText.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEpD,MAAM,GAAG,GAAG,EAAE,CAAC;AACf,MAAM,YAAY,GAAG,IAAI,GAAG,GAAG,CAAC;AAChC,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAC7B;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,IAAY,EACZ,EACE,WAAW,GAAG,GAAG,MAMf,EAAE;IAEN,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IACrD,MAAM,WAAW,GAAG,MAAM,CAAC;QACzB,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,WAAW;QACrD,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,KAAK,EAAE,IAAI,CAAC,GAAG,EAAE;QACjB,aAAa,EAAE,IAAI,CAAC,MAAM;QAC1B,UAAU,EAAE,WAAW,GAAG,IAAI;KAC/B,CAAC,CAAC;IAEH,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAE7D,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO;QACT,CAAC;QACD,MAAM,gBAAgB,GACpB,CAAC,IAAI,CAAC,MAAM,GAAG,WAAW,CAAC,OAAO,CAAC,aAAa,CAAC;YACjD,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC3C,sEAAsE;QACtE,WAAW,CAAC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,CACvC,CAAC,CAAC,GAAG,gBAAgB,GAAG,WAAW,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAC3D,WAAW,CAAC,OAAO,CAAC,UAAU,GAAG,CAAC,CACnC,CAAC;QAEF,SAAS,MAAM;YACb,IAAI,WAAW,CAAC,OAAO,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;gBAC9C,OAAO;YACT,CAAC;YACD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACvB,MAAM,mBAAmB,GAAG,IAAI,CAAC,GAAG,CAClC,gBAAgB,EAChB,GAAG,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAC/B,CAAC;YACF,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CACtB,mBAAmB,GAAG,WAAW,CAAC,OAAO,CAAC,UAAU,CACrD,CAAC;YACF,WAAW,CAAC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CACnC,WAAW,CAAC,OAAO,CAAC,MAAM,GAAG,KAAK,EAClC,IAAI,CAAC,MAAM,CACZ,CAAC;YACF,WAAW,CAAC,OAAO,CAAC,IAAI,GAAG,GAAG,CAAC;YAC/B,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;QAC5D,CAAC;QACD,MAAM,EAAE,CAAC;QACT,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QACnD,OAAO,GAAG,EAAE,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IACvC,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,CAAC;IAErC,OAAO,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;AAC5E,CAAC"}
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/agent/issues"
9
9
  },
10
- "version": "0.1.4-alpha.0",
10
+ "version": "0.1.4",
11
11
  "license": "Apache-2.0",
12
12
  "keywords": [
13
13
  "convex",
@@ -105,6 +105,9 @@
105
105
  "chokidar-cli": "^3.0.0",
106
106
  "convex-test": "^0.0.37",
107
107
  "eslint": "^9.24.0",
108
+ "eslint-plugin-jsx-a11y": "^6.8.0",
109
+ "eslint-plugin-react": "^7.34.0",
110
+ "eslint-plugin-react-hooks": "^5.2.0",
108
111
  "globals": "^15.15.0",
109
112
  "prettier": "3.2.5",
110
113
  "typescript": "^5.8.3",
@@ -9,7 +9,7 @@ export type StreamingOptions = {
9
9
  * Note: this is not a guarantee that every delta will be exactly one line.
10
10
  * E.g. if "line" is specified, it won't save any deltas until it encounters
11
11
  * a newline character.
12
- * Defaults to "word".
12
+ * Defaults to a regex that chunks by punctuation followed by whitespace.
13
13
  */
14
14
  chunking?: "word" | "line" | RegExp | ChunkDetector;
15
15
  /**
@@ -19,7 +19,8 @@ export type StreamingOptions = {
19
19
  throttleMs?: number;
20
20
  };
21
21
  export const DEFAULT_STREAMING_OPTIONS = {
22
- chunking: "word",
22
+ // This chunks by sentences / clauses. Punctuation followed by whitespace.
23
+ chunking: /[\p{P}\s]/u,
23
24
  throttleMs: 250,
24
25
  } satisfies StreamingOptions;
25
26
 
@@ -51,10 +51,9 @@ export const listDeltas = query({
51
51
  );
52
52
  totalDeltas += streamDeltas.length;
53
53
  deltas.push(
54
- ...streamDeltas.map((d) => ({
55
- streamId: d.streamId.toString(),
56
- ...pick(d, ["start", "end", "parts"]),
57
- }))
54
+ ...streamDeltas.map((d) =>
55
+ pick(d, ["streamId", "start", "end", "parts"])
56
+ )
58
57
  );
59
58
  if (totalDeltas >= MAX_DELTAS_PER_REQUEST) {
60
59
  break;
@@ -0,0 +1,298 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mergeDeltas, applyDeltasToStreamMessage } from "./deltas";
3
+ import type { StreamMessage, StreamDelta, TextStreamPart } from "../validators";
4
+
5
+ function makeStreamMessage(
6
+ streamId: string,
7
+ order: number,
8
+ stepOrder: number
9
+ ): StreamMessage {
10
+ return {
11
+ streamId,
12
+ order,
13
+ stepOrder,
14
+ } as StreamMessage;
15
+ }
16
+
17
+ function makeDelta(
18
+ streamId: string,
19
+ start: number,
20
+ end: number,
21
+ parts: TextStreamPart[]
22
+ ): StreamDelta {
23
+ return {
24
+ streamId,
25
+ start,
26
+ end,
27
+ parts,
28
+ };
29
+ }
30
+
31
+ describe("mergeDeltas", () => {
32
+ it("merges a single text-delta into a message", () => {
33
+ const streamId = "s1";
34
+ const streamMessages = [makeStreamMessage(streamId, 1, 0)];
35
+ const deltas = [
36
+ makeDelta(streamId, 0, 5, [{ type: "text-delta", textDelta: "Hello" }]),
37
+ ];
38
+ const [messages, newStreams, changed] = mergeDeltas(
39
+ "thread1",
40
+ streamMessages,
41
+ [],
42
+ deltas
43
+ );
44
+ expect(messages).toHaveLength(1);
45
+ expect(messages[0].text).toBe("Hello");
46
+ expect(messages[0].message?.role).toBe("assistant");
47
+ expect(changed).toBe(true);
48
+ expect(newStreams[0].cursor).toBe(5);
49
+ });
50
+
51
+ it("merges multiple deltas for the same stream", () => {
52
+ const streamId = "s1";
53
+ const streamMessages = [makeStreamMessage(streamId, 1, 0)];
54
+ const deltas = [
55
+ makeDelta(streamId, 0, 5, [{ type: "text-delta", textDelta: "Hello" }]),
56
+ makeDelta(streamId, 5, 11, [
57
+ { type: "text-delta", textDelta: " World!" },
58
+ ]),
59
+ ];
60
+ const [messages, newStreams, changed] = mergeDeltas(
61
+ "thread1",
62
+ streamMessages,
63
+ [],
64
+ deltas
65
+ );
66
+ expect(messages).toHaveLength(1);
67
+ expect(messages[0].text).toBe("Hello World!");
68
+ expect(changed).toBe(true);
69
+ expect(newStreams[0].cursor).toBe(11);
70
+ });
71
+
72
+ it("handles tool-call and tool-result parts", () => {
73
+ const streamId = "s2";
74
+ const streamMessages = [makeStreamMessage(streamId, 2, 0)];
75
+ const deltas = [
76
+ makeDelta(streamId, 0, 1, [
77
+ {
78
+ type: "tool-call",
79
+ toolCallId: "call1",
80
+ toolName: "myTool",
81
+ args: "",
82
+ },
83
+ ]),
84
+ makeDelta(streamId, 1, 2, [
85
+ {
86
+ type: "tool-result",
87
+ toolCallId: "call1",
88
+ toolName: "myTool",
89
+ result: "42",
90
+ },
91
+ ]),
92
+ ];
93
+ const [messages, _, changed] = mergeDeltas(
94
+ "thread1",
95
+ streamMessages,
96
+ [],
97
+ deltas
98
+ );
99
+ expect(messages).toHaveLength(2);
100
+ expect(messages[0].message?.role).toBe("assistant");
101
+ expect(messages[0].tool).toBe(true);
102
+ const content = messages[0].message?.content;
103
+ expect(content).toEqual([
104
+ {
105
+ type: "tool-call",
106
+ toolCallId: "call1",
107
+ toolName: "myTool",
108
+ args: "",
109
+ },
110
+ ]);
111
+ expect(messages[1].message?.role).toBe("tool");
112
+ expect(messages[1].tool).toBe(true);
113
+ expect(messages[1].message?.content).toEqual([
114
+ {
115
+ type: "tool-result",
116
+ toolCallId: "call1",
117
+ toolName: "myTool",
118
+ result: "42",
119
+ },
120
+ ]);
121
+ expect(changed).toBe(true);
122
+ });
123
+
124
+ it("returns changed=false if no new deltas", () => {
125
+ const streamId = "s3";
126
+ const streamMessages = [makeStreamMessage(streamId, 3, 0)];
127
+ const deltas: StreamDelta[] = [];
128
+ const [messages, newStreams, changed] = mergeDeltas(
129
+ "thread1",
130
+ streamMessages,
131
+ [],
132
+ deltas
133
+ );
134
+ expect(messages).toHaveLength(0);
135
+ expect(changed).toBe(false);
136
+ expect(newStreams[0].cursor).toBe(0);
137
+ });
138
+
139
+ it("handles multiple streams and sorts by order/stepOrder", () => {
140
+ const s1 = makeStreamMessage("s1", 1, 0);
141
+ const s2 = makeStreamMessage("s2", 2, 0);
142
+ const deltas = [
143
+ makeDelta("s2", 0, 3, [{ type: "text-delta", textDelta: "B" }]),
144
+ makeDelta("s1", 0, 3, [{ type: "text-delta", textDelta: "A" }]),
145
+ ];
146
+ const [messages, _, changed] = mergeDeltas("thread1", [s2, s1], [], deltas);
147
+ expect(messages).toHaveLength(2);
148
+ expect(messages[0].text).toBe("A");
149
+ expect(messages[1].text).toBe("B");
150
+ expect(changed).toBe(true);
151
+ // Sorted by order
152
+ expect(messages[0].order).toBe(1);
153
+ expect(messages[1].order).toBe(2);
154
+ });
155
+
156
+ it("does not duplicate text content when merging sequential text-deltas", () => {
157
+ const streamId = "s4";
158
+ const streamMessages = [makeStreamMessage(streamId, 4, 0)];
159
+ const deltas = [
160
+ makeDelta(streamId, 0, 5, [{ type: "text-delta", textDelta: "Hello" }]),
161
+ makeDelta(streamId, 5, 11, [
162
+ { type: "text-delta", textDelta: " World!" },
163
+ ]),
164
+ makeDelta(streamId, 11, 12, [{ type: "text-delta", textDelta: "!" }]),
165
+ ];
166
+ const [messages] = mergeDeltas("thread1", streamMessages, [], deltas);
167
+ expect(messages).toHaveLength(1);
168
+ expect(messages[0].text).toBe("Hello World!!");
169
+ // There should only be one text part per message
170
+ const content = messages[0].message?.content;
171
+ if (Array.isArray(content)) {
172
+ const textParts = content.filter((p) => p.type === "text");
173
+ expect(textParts).toHaveLength(1);
174
+ expect(textParts[0].text).toBe("Hello World!!");
175
+ }
176
+ });
177
+
178
+ it("does not duplicate reasoning parts", () => {
179
+ const streamId = "s6";
180
+ const streamMessages = [makeStreamMessage(streamId, 6, 0)];
181
+ const deltas = [
182
+ makeDelta(streamId, 0, 1, [
183
+ { type: "reasoning", textDelta: "I'm thinking..." },
184
+ ]),
185
+ makeDelta(streamId, 1, 2, [
186
+ { type: "reasoning", textDelta: " Still thinking..." },
187
+ ]),
188
+ ];
189
+ const [messages] = mergeDeltas("thread1", streamMessages, [], deltas);
190
+ expect(messages).toHaveLength(1);
191
+ if (Array.isArray(messages[0].message?.content)) {
192
+ const reasoningParts = messages[0].message.content.filter(
193
+ (p) => p.type === "reasoning"
194
+ );
195
+ expect(reasoningParts).toHaveLength(1);
196
+ expect(reasoningParts[0].text).toBe("I'm thinking... Still thinking...");
197
+ }
198
+ });
199
+
200
+ it("applyDeltasToStreamMessage is idempotent and does not duplicate content", () => {
201
+ const streamId = "s7";
202
+ const streamMessage = makeStreamMessage(streamId, 7, 0);
203
+ const deltas = [
204
+ makeDelta(streamId, 0, 5, [{ type: "text-delta", textDelta: "Hello" }]),
205
+ makeDelta(streamId, 5, 11, [
206
+ { type: "text-delta", textDelta: " World!" },
207
+ ]),
208
+ ];
209
+ // First call: apply both deltas
210
+ let [result, changed] = applyDeltasToStreamMessage(
211
+ "thread1",
212
+ streamMessage,
213
+ undefined,
214
+ deltas
215
+ );
216
+ expect(result.messages).toHaveLength(1);
217
+ expect(result.messages[0].text).toBe("Hello World!");
218
+ // Second call: re-apply the same deltas (should not duplicate)
219
+ [result, changed] = applyDeltasToStreamMessage(
220
+ "thread1",
221
+ streamMessage,
222
+ result,
223
+ deltas
224
+ );
225
+ expect(result.messages).toHaveLength(1);
226
+ expect(result.messages[0].text).toBe("Hello World!");
227
+ // Third call: add a new delta
228
+ const moreDeltas = [
229
+ ...deltas,
230
+ makeDelta(streamId, 11, 12, [{ type: "text-delta", textDelta: "!" }]),
231
+ ];
232
+ [result, changed] = applyDeltasToStreamMessage(
233
+ "thread1",
234
+ streamMessage,
235
+ result,
236
+ moreDeltas
237
+ );
238
+ expect(changed).toBe(true);
239
+ expect(result.messages).toHaveLength(1);
240
+ expect(result.messages[0].text).toBe("Hello World!!");
241
+ // Re-apply all deltas again (should still not duplicate)
242
+ [result, changed] = applyDeltasToStreamMessage(
243
+ "thread1",
244
+ streamMessage,
245
+ result,
246
+ moreDeltas
247
+ );
248
+ expect(changed).toBe(false);
249
+ expect(result.messages).toHaveLength(1);
250
+ expect(result.messages[0].text).toBe("Hello World!!");
251
+ });
252
+
253
+ it("mergeDeltas is pure and does not mutate inputs", () => {
254
+ const streamId = "s8";
255
+ const streamMessages = [makeStreamMessage(streamId, 8, 0)];
256
+ const deltas = [
257
+ makeDelta(streamId, 0, 5, [{ type: "text-delta", textDelta: "Hello" }]),
258
+ makeDelta(streamId, 5, 11, [
259
+ { type: "text-delta", textDelta: " World!" },
260
+ ]),
261
+ ];
262
+ // Deep freeze inputs to catch mutation
263
+ function deepFreeze(obj: unknown): unknown {
264
+ if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
265
+ Object.freeze(obj);
266
+ for (const key of Object.keys(obj)) {
267
+ deepFreeze((obj as Record<string, unknown>)[key]);
268
+ }
269
+ }
270
+ return obj;
271
+ }
272
+ deepFreeze(streamMessages);
273
+ deepFreeze(deltas);
274
+ const [messages1, streams1, changed1] = mergeDeltas(
275
+ "thread1",
276
+ streamMessages,
277
+ [],
278
+ deltas
279
+ );
280
+ const [messages2, streams2, changed2] = mergeDeltas(
281
+ "thread1",
282
+ streamMessages,
283
+ [],
284
+ deltas
285
+ );
286
+ expect(messages1).toEqual(messages2);
287
+ expect(streams1).toEqual(streams2);
288
+ expect(changed1).toBe(changed2);
289
+ // Inputs should remain unchanged
290
+ expect(streamMessages).toEqual([makeStreamMessage(streamId, 8, 0)]);
291
+ expect(deltas).toEqual([
292
+ makeDelta(streamId, 0, 5, [{ type: "text-delta", textDelta: "Hello" }]),
293
+ makeDelta(streamId, 5, 11, [
294
+ { type: "text-delta", textDelta: " World!" },
295
+ ]),
296
+ ]);
297
+ });
298
+ });