@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.
- package/dist/commonjs/client/streaming.d.ts +2 -2
- package/dist/commonjs/client/streaming.d.ts.map +1 -1
- package/dist/commonjs/client/streaming.js +2 -1
- package/dist/commonjs/client/streaming.js.map +1 -1
- package/dist/commonjs/component/streams.d.ts.map +1 -1
- package/dist/commonjs/component/streams.js +1 -4
- package/dist/commonjs/component/streams.js.map +1 -1
- package/dist/commonjs/react/deltas.d.ts +29 -0
- package/dist/commonjs/react/deltas.d.ts.map +1 -0
- package/dist/commonjs/react/deltas.js +267 -0
- package/dist/commonjs/react/deltas.js.map +1 -0
- package/dist/commonjs/react/index.d.ts +4 -49
- package/dist/commonjs/react/index.d.ts.map +1 -1
- package/dist/commonjs/react/index.js +15 -446
- package/dist/commonjs/react/index.js.map +1 -1
- package/dist/commonjs/react/optimisticallySendMessage.d.ts +8 -0
- package/dist/commonjs/react/optimisticallySendMessage.d.ts.map +1 -0
- package/dist/commonjs/react/optimisticallySendMessage.js +40 -0
- package/dist/commonjs/react/optimisticallySendMessage.js.map +1 -0
- package/dist/commonjs/react/toUIMessages.d.ts.map +1 -1
- package/dist/commonjs/react/toUIMessages.js +4 -2
- package/dist/commonjs/react/toUIMessages.js.map +1 -1
- package/dist/commonjs/react/types.d.ts +26 -0
- package/dist/commonjs/react/types.d.ts.map +1 -0
- package/dist/commonjs/react/types.js +2 -0
- package/dist/commonjs/react/types.js.map +1 -0
- package/dist/commonjs/react/useSmoothText.d.ts +20 -0
- package/dist/commonjs/react/useSmoothText.d.ts.map +1 -0
- package/dist/commonjs/react/useSmoothText.js +50 -0
- package/dist/commonjs/react/useSmoothText.js.map +1 -0
- package/dist/esm/client/streaming.d.ts +2 -2
- package/dist/esm/client/streaming.d.ts.map +1 -1
- package/dist/esm/client/streaming.js +2 -1
- package/dist/esm/client/streaming.js.map +1 -1
- package/dist/esm/component/streams.d.ts.map +1 -1
- package/dist/esm/component/streams.js +1 -4
- package/dist/esm/component/streams.js.map +1 -1
- package/dist/esm/react/deltas.d.ts +29 -0
- package/dist/esm/react/deltas.d.ts.map +1 -0
- package/dist/esm/react/deltas.js +267 -0
- package/dist/esm/react/deltas.js.map +1 -0
- package/dist/esm/react/index.d.ts +4 -49
- package/dist/esm/react/index.d.ts.map +1 -1
- package/dist/esm/react/index.js +15 -446
- package/dist/esm/react/index.js.map +1 -1
- package/dist/esm/react/optimisticallySendMessage.d.ts +8 -0
- package/dist/esm/react/optimisticallySendMessage.d.ts.map +1 -0
- package/dist/esm/react/optimisticallySendMessage.js +40 -0
- package/dist/esm/react/optimisticallySendMessage.js.map +1 -0
- package/dist/esm/react/toUIMessages.d.ts.map +1 -1
- package/dist/esm/react/toUIMessages.js +4 -2
- package/dist/esm/react/toUIMessages.js.map +1 -1
- package/dist/esm/react/types.d.ts +26 -0
- package/dist/esm/react/types.d.ts.map +1 -0
- package/dist/esm/react/types.js +2 -0
- package/dist/esm/react/types.js.map +1 -0
- package/dist/esm/react/useSmoothText.d.ts +20 -0
- package/dist/esm/react/useSmoothText.d.ts.map +1 -0
- package/dist/esm/react/useSmoothText.js +50 -0
- package/dist/esm/react/useSmoothText.js.map +1 -0
- package/package.json +4 -1
- package/src/client/streaming.ts +3 -2
- package/src/component/streams.ts +3 -4
- package/src/react/deltas.test.ts +298 -0
- package/src/react/deltas.ts +340 -0
- package/src/react/index.ts +26 -589
- package/src/react/optimisticallySendMessage.ts +46 -0
- package/src/react/toUIMessages.test.ts +301 -0
- package/src/react/toUIMessages.ts +6 -2
- package/src/react/types.ts +52 -0
- 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 @@
|
|
|
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
|
|
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",
|
package/src/client/streaming.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/src/component/streams.ts
CHANGED
|
@@ -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
|
|
56
|
-
|
|
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
|
+
});
|