assistant-stream 0.3.26 → 0.3.27
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/core/AssistantStream.d.ts.map +1 -1
- package/dist/core/AssistantStreamChunk.d.ts +3 -3
- package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
- package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
- package/dist/core/converters/toGenericMessages.js.map +1 -1
- package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
- package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
- package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
- package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.js +51 -0
- package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
- package/dist/core/gorp/changeTree.d.ts +16 -0
- package/dist/core/gorp/changeTree.d.ts.map +1 -0
- package/dist/core/gorp/changeTree.js +77 -0
- package/dist/core/gorp/changeTree.js.map +1 -0
- package/dist/core/gorp/createGorpStream.d.ts +16 -0
- package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
- package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
- package/dist/core/gorp/createGorpStream.js.map +1 -0
- package/dist/core/{object → gorp}/types.d.ts +6 -5
- package/dist/core/gorp/types.d.ts.map +1 -0
- package/dist/core/modules/assistant-stream.d.ts +5 -4
- package/dist/core/modules/assistant-stream.d.ts.map +1 -1
- package/dist/core/modules/assistant-stream.js +10 -6
- package/dist/core/modules/assistant-stream.js.map +1 -1
- package/dist/core/modules/tool-call.js +2 -2
- package/dist/core/modules/tool-call.js.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.d.ts +2 -2
- package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js +11 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
- package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
- package/dist/core/tool/ToolCallReader.js.map +1 -1
- package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
- package/dist/core/tool/ToolExecutionStream.js +9 -1
- package/dist/core/tool/ToolExecutionStream.js.map +1 -1
- package/dist/core/tool/ToolResponse.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.js +7 -2
- package/dist/core/tool/toolResultStream.js.map +1 -1
- package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.d.ts +2 -0
- package/dist/core/utils/stream/merge.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.js +27 -8
- package/dist/core/utils/stream/merge.js.map +1 -1
- package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -0
- package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
- package/dist/resumable/ResumableStreamContext.js +12 -5
- package/dist/resumable/ResumableStreamContext.js.map +1 -1
- package/dist/resumable/errors.d.ts.map +1 -1
- package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.js +18 -3
- package/dist/utils/AsyncIterableStream.js.map +1 -1
- package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/AssistantStreamChunk.ts +3 -3
- package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
- package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
- package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
- package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
- package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
- package/src/core/gorp/GorpStreamDeltaTracker.test.ts +182 -0
- package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
- package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
- package/src/core/gorp/GorpStreamResponse.ts +88 -0
- package/src/core/gorp/changeTree.test.ts +168 -0
- package/src/core/gorp/changeTree.ts +96 -0
- package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
- package/src/core/{object → gorp}/types.ts +5 -3
- package/src/core/modules/assistant-stream.test.ts +154 -2
- package/src/core/modules/assistant-stream.ts +16 -6
- package/src/core/modules/tool-call.test.ts +67 -0
- package/src/core/modules/tool-call.ts +3 -3
- package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
- package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
- package/src/core/serialization/data-stream/chunk-types.ts +2 -2
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +111 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +20 -1
- package/src/core/tool/ToolExecutionStream.ts +10 -0
- package/src/core/tool/toolResultStream.test.ts +50 -0
- package/src/core/tool/toolResultStream.ts +7 -2
- package/src/core/utils/stream/merge.ts +31 -9
- package/src/gorp.test.ts +42 -0
- package/src/index.ts +21 -6
- package/src/resumable/ResumableStreamContext.test.ts +116 -1
- package/src/resumable/ResumableStreamContext.ts +32 -5
- package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
- package/src/utils/AsyncIterableStream.test.ts +61 -0
- package/src/utils/AsyncIterableStream.ts +18 -3
- package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
- package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
- package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
- package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamResponse.js +0 -57
- package/dist/core/object/ObjectStreamResponse.js.map +0 -1
- package/dist/core/object/createObjectStream.d.ts +0 -16
- package/dist/core/object/createObjectStream.d.ts.map +0 -1
- package/dist/core/object/createObjectStream.js.map +0 -1
- package/dist/core/object/types.d.ts.map +0 -1
- package/src/core/object/ObjectStreamResponse.ts +0 -114
- /package/dist/core/{object → gorp}/types.js +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { GorpStreamDeltaTracker } from "./GorpStreamDeltaTracker";
|
|
3
|
+
import { createGorpStream } from "./createGorpStream";
|
|
4
|
+
import type { ReadonlyJSONObject } from "../../utils";
|
|
5
|
+
|
|
6
|
+
describe("GorpStreamDeltaTracker frames", () => {
|
|
7
|
+
it("reports ops of the current frame in getChangedKeys", () => {
|
|
8
|
+
const tracker = new GorpStreamDeltaTracker({ items: {} });
|
|
9
|
+
tracker.append([
|
|
10
|
+
{ type: "set", path: ["items", "a"], value: { name: "alpha" } },
|
|
11
|
+
]);
|
|
12
|
+
expect(tracker.getChangedKeys(["items"])).toEqual(["a"]);
|
|
13
|
+
expect((tracker.state as ReadonlyJSONObject).items).toEqual({
|
|
14
|
+
a: { name: "alpha" },
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("opens a separate frame per append", () => {
|
|
19
|
+
const tracker = new GorpStreamDeltaTracker({ items: {} });
|
|
20
|
+
tracker.append([
|
|
21
|
+
{ type: "set", path: ["items", "a"], value: { name: "alpha" } },
|
|
22
|
+
]);
|
|
23
|
+
tracker.append([
|
|
24
|
+
{ type: "set", path: ["items", "b"], value: { name: "beta" } },
|
|
25
|
+
]);
|
|
26
|
+
expect(tracker.getChangedKeys(["items"])).toEqual(["b"]);
|
|
27
|
+
expect(tracker.isChangedAt(["items", "a"])).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reports an empty change set before the first append and after an empty append", () => {
|
|
31
|
+
const tracker = new GorpStreamDeltaTracker({ count: 0 });
|
|
32
|
+
expect(tracker.isChangedAt([])).toBe(false);
|
|
33
|
+
expect(tracker.isChangedAt(["count"])).toBe(false);
|
|
34
|
+
expect(tracker.getChangedKeys([])).toEqual([]);
|
|
35
|
+
tracker.append([{ type: "set", path: ["count"], value: 1 }]);
|
|
36
|
+
expect(tracker.isChangedAt([])).toBe(true);
|
|
37
|
+
tracker.append([]);
|
|
38
|
+
expect(tracker.isChangedAt([])).toBe(false);
|
|
39
|
+
expect(tracker.isChangedAt(["count"])).toBe(false);
|
|
40
|
+
expect(tracker.getChangedKeys([])).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("GorpStreamDeltaTracker isChangedAt", () => {
|
|
45
|
+
it("returns truthy for any path inside a marked subtree", () => {
|
|
46
|
+
const tracker = new GorpStreamDeltaTracker();
|
|
47
|
+
tracker.append([{ type: "set", path: [], value: { items: {} } }]);
|
|
48
|
+
expect(tracker.isChangedAt([])).toBe(true);
|
|
49
|
+
expect(tracker.isChangedAt(["items"])).toBe(true);
|
|
50
|
+
expect(tracker.isChangedAt(["items", "anything", "deep"])).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns true for ancestors of a changed path and false for siblings", () => {
|
|
54
|
+
const tracker = new GorpStreamDeltaTracker({ a: { b: "x" }, c: "y" });
|
|
55
|
+
tracker.append([{ type: "set", path: ["a", "b"], value: "z" }]);
|
|
56
|
+
expect(tracker.isChangedAt([])).toBe(true);
|
|
57
|
+
expect(tracker.isChangedAt(["a"])).toBe(true);
|
|
58
|
+
expect(tracker.isChangedAt(["a", "b"])).toBe(true);
|
|
59
|
+
expect(tracker.isChangedAt(["c"])).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("marks append-text targets as changed", () => {
|
|
63
|
+
const tracker = new GorpStreamDeltaTracker({ message: "Hello" });
|
|
64
|
+
tracker.append([{ type: "append-text", path: ["message"], value: "!" }]);
|
|
65
|
+
expect(tracker.isChangedAt(["message"])).toBe(true);
|
|
66
|
+
expect(tracker.getChangedKeys([])).toEqual(["message"]);
|
|
67
|
+
expect((tracker.state as ReadonlyJSONObject).message).toBe("Hello!");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("GorpStreamDeltaTracker getChangedKeys", () => {
|
|
72
|
+
it("diffs keys against the previous frame when a subtree is fully replaced", () => {
|
|
73
|
+
const tracker = new GorpStreamDeltaTracker({ count: 0, items: {} });
|
|
74
|
+
tracker.append([
|
|
75
|
+
{ type: "set", path: ["items", "a"], value: { name: "alpha" } },
|
|
76
|
+
]);
|
|
77
|
+
tracker.append([
|
|
78
|
+
{ type: "set", path: ["items", "b"], value: { name: "beta" } },
|
|
79
|
+
]);
|
|
80
|
+
tracker.append([
|
|
81
|
+
{
|
|
82
|
+
type: "set",
|
|
83
|
+
path: [],
|
|
84
|
+
value: {
|
|
85
|
+
count: 99,
|
|
86
|
+
items: { b: { name: "beta-new" }, c: { name: "gamma" } },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
expect(tracker.getChangedKeys(["items"])).toEqual(["b", "c", "a"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns interior node keys for a partially changed subtree", () => {
|
|
94
|
+
const tracker = new GorpStreamDeltaTracker({ items: { a: 1, b: 2 } });
|
|
95
|
+
tracker.append([
|
|
96
|
+
{ type: "set", path: ["items", "a"], value: 10 },
|
|
97
|
+
{ type: "set", path: ["items", "c"], value: 3 },
|
|
98
|
+
]);
|
|
99
|
+
expect(tracker.getChangedKeys(["items"])).toEqual(["a", "c"]);
|
|
100
|
+
expect(tracker.getChangedKeys([])).toEqual(["items"]);
|
|
101
|
+
expect(tracker.getChangedKeys(["other"])).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("diffs keys when the previous value at a replaced path was not an object", () => {
|
|
105
|
+
const tracker = new GorpStreamDeltaTracker({ value: "initial" });
|
|
106
|
+
tracker.append([{ type: "set", path: ["value"], value: { nested: true } }]);
|
|
107
|
+
expect(tracker.getChangedKeys(["value"])).toEqual(["nested"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("GorpStreamDeltaTracker inherited object members", () => {
|
|
112
|
+
it("does not treat inherited members as changes on a fresh tracker", () => {
|
|
113
|
+
const tracker = new GorpStreamDeltaTracker({ items: {} });
|
|
114
|
+
expect(tracker.isChangedAt(["toString"])).toBe(false);
|
|
115
|
+
expect(tracker.isChangedAt(["hasOwnProperty"])).toBe(false);
|
|
116
|
+
expect(tracker.getChangedKeys(["toString"])).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not treat inherited members as changes on a populated tree", () => {
|
|
120
|
+
const tracker = new GorpStreamDeltaTracker({ items: {} });
|
|
121
|
+
tracker.append([{ type: "set", path: ["items", "a"], value: 1 }]);
|
|
122
|
+
expect(tracker.isChangedAt(["toString"])).toBe(false);
|
|
123
|
+
expect(tracker.isChangedAt(["items", "toString"])).toBe(false);
|
|
124
|
+
expect(tracker.isChangedAt(["items", "valueOf", "deep"])).toBe(false);
|
|
125
|
+
expect(tracker.getChangedKeys([])).toEqual(["items"]);
|
|
126
|
+
expect(tracker.getChangedKeys(["items"])).toEqual(["a"]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("GorpStreamDeltaTracker failed append", () => {
|
|
131
|
+
it("leaves state and the change frame untouched when an operation throws", () => {
|
|
132
|
+
const tracker = new GorpStreamDeltaTracker({ count: 5, message: "hi" });
|
|
133
|
+
tracker.append([{ type: "set", path: ["message"], value: "hello" }]);
|
|
134
|
+
const stateBefore = tracker.state;
|
|
135
|
+
|
|
136
|
+
expect(() =>
|
|
137
|
+
tracker.append([
|
|
138
|
+
{ type: "set", path: ["other"], value: 1 },
|
|
139
|
+
{ type: "append-text", path: ["count"], value: "!" },
|
|
140
|
+
]),
|
|
141
|
+
).toThrow(/Expected string/);
|
|
142
|
+
|
|
143
|
+
expect(tracker.state).toBe(stateBefore);
|
|
144
|
+
expect(tracker.isChangedAt(["message"])).toBe(true);
|
|
145
|
+
expect(tracker.isChangedAt(["other"])).toBe(false);
|
|
146
|
+
expect(tracker.isChangedAt(["count"])).toBe(false);
|
|
147
|
+
expect(tracker.getChangedKeys([])).toEqual(["message"]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("GorpStreamDeltaTracker streamed accumulation", () => {
|
|
152
|
+
it("tracks one frame per streamed chunk", async () => {
|
|
153
|
+
const stream = createGorpStream({
|
|
154
|
+
execute: (controller) => {
|
|
155
|
+
controller.enqueue([
|
|
156
|
+
{ type: "set", path: ["user"], value: { name: "Initial" } },
|
|
157
|
+
]);
|
|
158
|
+
controller.enqueue([
|
|
159
|
+
{ type: "set", path: ["user", "name"], value: "Updated" },
|
|
160
|
+
{ type: "set", path: ["user", "email"], value: "user@example.com" },
|
|
161
|
+
]);
|
|
162
|
+
controller.enqueue([
|
|
163
|
+
{ type: "set", path: ["status"], value: "complete" },
|
|
164
|
+
]);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const tracker = new GorpStreamDeltaTracker();
|
|
169
|
+
const frames: string[][] = [];
|
|
170
|
+
for await (const chunk of stream) {
|
|
171
|
+
tracker.append(chunk.operations);
|
|
172
|
+
frames.push(tracker.getChangedKeys(["user"]));
|
|
173
|
+
expect(tracker.state).toEqual(chunk.snapshot);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
expect(frames).toEqual([["name"], ["name", "email"], []]);
|
|
177
|
+
expect(tracker.state).toEqual({
|
|
178
|
+
user: { name: "Updated", email: "user@example.com" },
|
|
179
|
+
status: "complete",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReadonlyJSONValue } from "../../utils";
|
|
2
|
+
import { GorpStreamAccumulator } from "./GorpStreamAccumulator";
|
|
3
|
+
import type { GorpStreamOperation } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
type ChangeNode,
|
|
6
|
+
createChangeNode,
|
|
7
|
+
diffKeys,
|
|
8
|
+
lookupChange,
|
|
9
|
+
lookupValue,
|
|
10
|
+
markChanged,
|
|
11
|
+
} from "./changeTree";
|
|
12
|
+
|
|
13
|
+
export class GorpStreamDeltaTracker {
|
|
14
|
+
private readonly accumulator: GorpStreamAccumulator;
|
|
15
|
+
private previousState: ReadonlyJSONValue;
|
|
16
|
+
private changes: ChangeNode = createChangeNode();
|
|
17
|
+
|
|
18
|
+
constructor(initialValue: ReadonlyJSONValue = null) {
|
|
19
|
+
this.accumulator = new GorpStreamAccumulator(initialValue);
|
|
20
|
+
this.previousState = this.accumulator.state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get state(): ReadonlyJSONValue {
|
|
24
|
+
return this.accumulator.state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
append(operations: readonly GorpStreamOperation[]): void {
|
|
28
|
+
const previousState = this.accumulator.state;
|
|
29
|
+
let changes: ChangeNode = createChangeNode();
|
|
30
|
+
for (const op of operations) {
|
|
31
|
+
changes = markChanged(changes, op.path);
|
|
32
|
+
}
|
|
33
|
+
this.accumulator.append(operations);
|
|
34
|
+
this.previousState = previousState;
|
|
35
|
+
this.changes = changes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
isChangedAt(path: readonly string[]): boolean {
|
|
39
|
+
const node = lookupChange(this.changes, path);
|
|
40
|
+
if (node === true) return true;
|
|
41
|
+
return node !== false && Object.keys(node).length > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getChangedKeys(path: readonly string[]): string[] {
|
|
45
|
+
const node = lookupChange(this.changes, path);
|
|
46
|
+
if (node === false) return [];
|
|
47
|
+
if (node === true) {
|
|
48
|
+
return diffKeys(
|
|
49
|
+
lookupValue(this.accumulator.state, path),
|
|
50
|
+
lookupValue(this.previousState, path),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return Object.keys(node);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { fromGorpStreamResponse } from "./GorpStreamResponse";
|
|
3
3
|
|
|
4
4
|
const createResponse = (contentType?: string) => {
|
|
5
5
|
const headers = new Headers({
|
|
@@ -10,23 +10,23 @@ const createResponse = (contentType?: string) => {
|
|
|
10
10
|
return new Response(new Uint8Array(), { headers });
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
describe("
|
|
13
|
+
describe("fromGorpStreamResponse", () => {
|
|
14
14
|
it.each([
|
|
15
15
|
"text/event-stream",
|
|
16
16
|
"text/event-stream; charset=utf-8",
|
|
17
17
|
"Text/Event-Stream; Charset=UTF-8",
|
|
18
18
|
])("accepts the event-stream content type %s", (contentType) => {
|
|
19
19
|
expect(() =>
|
|
20
|
-
|
|
20
|
+
fromGorpStreamResponse(createResponse(contentType)),
|
|
21
21
|
).not.toThrow();
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
it.each([undefined, "application/json", "text/event-streaming"])(
|
|
25
25
|
"rejects the content type %s",
|
|
26
26
|
(contentType) => {
|
|
27
|
-
expect(() =>
|
|
28
|
-
|
|
29
|
-
)
|
|
27
|
+
expect(() => fromGorpStreamResponse(createResponse(contentType))).toThrow(
|
|
28
|
+
"Response is not an event stream",
|
|
29
|
+
);
|
|
30
30
|
},
|
|
31
31
|
);
|
|
32
32
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PipeableTransformStream } from "../utils/stream/PipeableTransformStream";
|
|
2
|
+
import { GorpStreamAccumulator } from "./GorpStreamAccumulator";
|
|
3
|
+
import { SSEDecoder, SSEEncoder } from "../utils/stream/SSE";
|
|
4
|
+
import type { GorpStreamChunk, GorpStreamOperation } from "./types";
|
|
5
|
+
|
|
6
|
+
export class GorpStreamEncoder extends PipeableTransformStream<
|
|
7
|
+
GorpStreamChunk,
|
|
8
|
+
Uint8Array
|
|
9
|
+
> {
|
|
10
|
+
constructor() {
|
|
11
|
+
let isFirstChunk = true;
|
|
12
|
+
super((readable) =>
|
|
13
|
+
readable
|
|
14
|
+
.pipeThrough(
|
|
15
|
+
new TransformStream<GorpStreamChunk, readonly GorpStreamOperation[]>({
|
|
16
|
+
transform(chunk, controller) {
|
|
17
|
+
if (isFirstChunk) {
|
|
18
|
+
controller.enqueue([
|
|
19
|
+
{ type: "set", path: [], value: chunk.snapshot },
|
|
20
|
+
]);
|
|
21
|
+
} else {
|
|
22
|
+
controller.enqueue(chunk.operations);
|
|
23
|
+
}
|
|
24
|
+
isFirstChunk = false;
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
.pipeThrough(new SSEEncoder()),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class GorpStreamDecoder extends PipeableTransformStream<
|
|
34
|
+
Uint8Array<ArrayBuffer>,
|
|
35
|
+
GorpStreamChunk
|
|
36
|
+
> {
|
|
37
|
+
constructor() {
|
|
38
|
+
const accumulator = new GorpStreamAccumulator();
|
|
39
|
+
super((readable) =>
|
|
40
|
+
readable
|
|
41
|
+
.pipeThrough(new SSEDecoder<readonly GorpStreamOperation[]>())
|
|
42
|
+
.pipeThrough(
|
|
43
|
+
new TransformStream<readonly GorpStreamOperation[], GorpStreamChunk>({
|
|
44
|
+
transform(operations, controller) {
|
|
45
|
+
accumulator.append(operations);
|
|
46
|
+
controller.enqueue({
|
|
47
|
+
snapshot: accumulator.state,
|
|
48
|
+
operations,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class GorpStreamResponse extends Response {
|
|
58
|
+
constructor(body: ReadableStream<GorpStreamChunk>) {
|
|
59
|
+
super(body.pipeThrough(new GorpStreamEncoder()), {
|
|
60
|
+
headers: new Headers({
|
|
61
|
+
"Content-Type": "text/event-stream",
|
|
62
|
+
"Cache-Control": "no-cache",
|
|
63
|
+
Connection: "keep-alive",
|
|
64
|
+
"Assistant-Stream-Format": "object-stream/v0",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const fromGorpStreamResponse = (
|
|
71
|
+
response: Response,
|
|
72
|
+
): ReadableStream<GorpStreamChunk> => {
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
throw new Error(`Response failed, status ${response.status}`);
|
|
75
|
+
if (!response.body) throw new Error("Response body is null");
|
|
76
|
+
const mediaType = response.headers
|
|
77
|
+
.get("Content-Type")
|
|
78
|
+
?.split(";", 1)[0]
|
|
79
|
+
?.trim()
|
|
80
|
+
.toLowerCase();
|
|
81
|
+
if (mediaType !== "text/event-stream") {
|
|
82
|
+
throw new Error("Response is not an event stream");
|
|
83
|
+
}
|
|
84
|
+
if (response.headers.get("Assistant-Stream-Format") !== "object-stream/v0") {
|
|
85
|
+
throw new Error("Unsupported Assistant-Stream-Format header");
|
|
86
|
+
}
|
|
87
|
+
return response.body.pipeThrough(new GorpStreamDecoder());
|
|
88
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
diffKeys,
|
|
4
|
+
lookupChange,
|
|
5
|
+
markChanged,
|
|
6
|
+
mergeChanged,
|
|
7
|
+
type ChangeNode,
|
|
8
|
+
} from "./changeTree";
|
|
9
|
+
|
|
10
|
+
describe("markChanged", () => {
|
|
11
|
+
it("collapses to true when path is empty", () => {
|
|
12
|
+
expect(markChanged({}, [])).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps `true` after attempting to descend into it", () => {
|
|
16
|
+
const node: ChangeNode = { a: true };
|
|
17
|
+
const result = markChanged(node, ["a", "b"]);
|
|
18
|
+
expect(result).toBe(node);
|
|
19
|
+
expect(node.a).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("overwrites a nested subtree with `true` when the path collapses to the parent", () => {
|
|
23
|
+
const node: ChangeNode = { a: { b: true } };
|
|
24
|
+
markChanged(node, ["a"]);
|
|
25
|
+
expect(node.a).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("creates intermediate nodes lazily without disturbing siblings", () => {
|
|
29
|
+
const node: ChangeNode = { a: { existing: true } };
|
|
30
|
+
markChanged(node, ["a", "b", "c"]);
|
|
31
|
+
expect(node).toEqual({
|
|
32
|
+
a: { existing: true, b: { c: true } },
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is idempotent when the same leaf is marked twice", () => {
|
|
37
|
+
const node: ChangeNode = {};
|
|
38
|
+
markChanged(node, ["a", "b"]);
|
|
39
|
+
markChanged(node, ["a", "b"]);
|
|
40
|
+
expect(node).toEqual({ a: { b: true } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects unsafe path segments", () => {
|
|
44
|
+
const node: ChangeNode = {};
|
|
45
|
+
expect(() => markChanged(node, ["__proto__", "polluted"])).toThrow(
|
|
46
|
+
/Unsafe gorp path segment/,
|
|
47
|
+
);
|
|
48
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("mergeChanged", () => {
|
|
53
|
+
it("returns true when either side is true", () => {
|
|
54
|
+
expect(mergeChanged(true, {})).toBe(true);
|
|
55
|
+
expect(mergeChanged({}, true)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("merges disjoint subtrees in place", () => {
|
|
59
|
+
const target: ChangeNode = { a: true };
|
|
60
|
+
const source: ChangeNode = { b: true };
|
|
61
|
+
const result = mergeChanged(target, source);
|
|
62
|
+
expect(result).toBe(target);
|
|
63
|
+
expect(target).toEqual({ a: true, b: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("clones source subtrees before assigning them", () => {
|
|
67
|
+
const target: ChangeNode = {};
|
|
68
|
+
const source: ChangeNode = { a: { b: true } };
|
|
69
|
+
mergeChanged(target, source);
|
|
70
|
+
(source as { a: { c?: true } }).a.c = true;
|
|
71
|
+
expect(target).toEqual({ a: { b: true } });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("deep-merges overlapping subtrees", () => {
|
|
75
|
+
const target: ChangeNode = { a: { x: true } };
|
|
76
|
+
const source: ChangeNode = { a: { y: true } };
|
|
77
|
+
mergeChanged(target, source);
|
|
78
|
+
expect(target).toEqual({ a: { x: true, y: true } });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("propagates `true` upward when one side has it at a shared key", () => {
|
|
82
|
+
const target: ChangeNode = { a: { x: true } };
|
|
83
|
+
const source: ChangeNode = { a: true };
|
|
84
|
+
mergeChanged(target, source);
|
|
85
|
+
expect(target).toEqual({ a: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects unsafe source keys", () => {
|
|
89
|
+
const target: ChangeNode = {};
|
|
90
|
+
const source = JSON.parse('{"__proto__":true}') as ChangeNode;
|
|
91
|
+
expect(() => mergeChanged(target, source)).toThrow(
|
|
92
|
+
/Unsafe gorp path segment/,
|
|
93
|
+
);
|
|
94
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects nested unsafe source keys before assigning a subtree", () => {
|
|
98
|
+
const target: ChangeNode = {};
|
|
99
|
+
const source = JSON.parse('{"a":{"constructor":true}}') as ChangeNode;
|
|
100
|
+
expect(() => mergeChanged(target, source)).toThrow(
|
|
101
|
+
/Unsafe gorp path segment/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("lookupChange", () => {
|
|
107
|
+
it("returns false for paths that don't exist", () => {
|
|
108
|
+
expect(lookupChange({ a: true }, ["b"])).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns true when traversal hits a `true` sentinel", () => {
|
|
112
|
+
expect(lookupChange({ a: true }, ["a", "b", "c"])).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns the subtree when the path lands on an interior node", () => {
|
|
116
|
+
const node: ChangeNode = { a: { b: true } };
|
|
117
|
+
const result = lookupChange(node, ["a"]);
|
|
118
|
+
expect(result).toEqual({ b: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns the root node for the empty path", () => {
|
|
122
|
+
const node: ChangeNode = { a: true };
|
|
123
|
+
expect(lookupChange(node, [])).toBe(node);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns false for inherited object members", () => {
|
|
127
|
+
expect(lookupChange({}, ["toString"])).toBe(false);
|
|
128
|
+
expect(lookupChange({ a: {} }, ["a", "hasOwnProperty"])).toBe(false);
|
|
129
|
+
expect(lookupChange({ a: true }, ["constructor"])).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("inherited object members", () => {
|
|
134
|
+
it("markChanged does not descend into inherited members", () => {
|
|
135
|
+
const node: ChangeNode = {};
|
|
136
|
+
markChanged(node, ["toString", "x"]);
|
|
137
|
+
expect(node).toEqual({ toString: { x: true } });
|
|
138
|
+
expect(lookupChange(node, ["toString", "x"])).toBe(true);
|
|
139
|
+
expect(lookupChange(node, ["valueOf"])).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("mergeChanged ignores inherited target members", () => {
|
|
143
|
+
const target: ChangeNode = {};
|
|
144
|
+
const source: ChangeNode = { toString: { x: true } };
|
|
145
|
+
mergeChanged(target, source);
|
|
146
|
+
expect(target).toEqual({ toString: { x: true } });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("diffKeys", () => {
|
|
151
|
+
it("returns new keys in order, then deleted keys in reverse", () => {
|
|
152
|
+
expect(diffKeys({ b: 1, c: 2 }, { a: 1, b: 2, d: 3 })).toEqual([
|
|
153
|
+
"b",
|
|
154
|
+
"c",
|
|
155
|
+
"d",
|
|
156
|
+
"a",
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns new keys when the old value is not an object", () => {
|
|
161
|
+
expect(diffKeys({ a: 1 }, null)).toEqual(["a"]);
|
|
162
|
+
expect(diffKeys({ a: 1 }, "str")).toEqual(["a"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns deleted keys when the new value is not an object", () => {
|
|
166
|
+
expect(diffKeys(null, { a: 1, b: 2 })).toEqual(["b", "a"]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type ChangeNode = true | { [key: string]: ChangeNode };
|
|
2
|
+
|
|
3
|
+
export function createChangeNode(): { [key: string]: ChangeNode } {
|
|
4
|
+
return Object.create(null);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
8
|
+
|
|
9
|
+
export function assertSafePathSegment(segment: string): void {
|
|
10
|
+
if (UNSAFE_PATH_SEGMENTS.has(segment)) {
|
|
11
|
+
throw new Error(`Unsafe gorp path segment: ${segment}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function markChanged(
|
|
16
|
+
node: ChangeNode,
|
|
17
|
+
path: readonly string[],
|
|
18
|
+
): ChangeNode {
|
|
19
|
+
if (node === true || path.length === 0) return true;
|
|
20
|
+
let cursor = node;
|
|
21
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
22
|
+
const key = path[i]!;
|
|
23
|
+
assertSafePathSegment(key);
|
|
24
|
+
const next = Object.hasOwn(cursor, key) ? cursor[key] : undefined;
|
|
25
|
+
if (next === true) return node;
|
|
26
|
+
if (next === undefined) cursor[key] = createChangeNode();
|
|
27
|
+
cursor = cursor[key] as { [k: string]: ChangeNode };
|
|
28
|
+
}
|
|
29
|
+
const leaf = path[path.length - 1]!;
|
|
30
|
+
assertSafePathSegment(leaf);
|
|
31
|
+
cursor[leaf] = true;
|
|
32
|
+
return node;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mergeChanged(
|
|
36
|
+
target: ChangeNode,
|
|
37
|
+
source: ChangeNode,
|
|
38
|
+
): ChangeNode {
|
|
39
|
+
if (target === true || source === true) return true;
|
|
40
|
+
for (const key of Object.keys(source)) {
|
|
41
|
+
assertSafePathSegment(key);
|
|
42
|
+
const sChild = source[key]!;
|
|
43
|
+
const tChild = Object.hasOwn(target, key) ? target[key] : undefined;
|
|
44
|
+
target[key] =
|
|
45
|
+
tChild === undefined
|
|
46
|
+
? cloneChangeNode(sChild)
|
|
47
|
+
: mergeChanged(tChild, sChild);
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cloneChangeNode(source: ChangeNode): ChangeNode {
|
|
53
|
+
if (source === true) return true;
|
|
54
|
+
const clone = createChangeNode();
|
|
55
|
+
for (const key of Object.keys(source)) {
|
|
56
|
+
assertSafePathSegment(key);
|
|
57
|
+
clone[key] = cloneChangeNode(source[key]!);
|
|
58
|
+
}
|
|
59
|
+
return clone;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function lookupChange(
|
|
63
|
+
node: ChangeNode,
|
|
64
|
+
path: readonly string[],
|
|
65
|
+
): ChangeNode | false {
|
|
66
|
+
for (const key of path) {
|
|
67
|
+
if (node === true) return true;
|
|
68
|
+
const next = Object.hasOwn(node, key) ? node[key] : undefined;
|
|
69
|
+
if (next === undefined) return false;
|
|
70
|
+
node = next;
|
|
71
|
+
}
|
|
72
|
+
return node;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function lookupValue(state: unknown, path: readonly string[]): unknown {
|
|
76
|
+
let node = state;
|
|
77
|
+
for (const key of path) {
|
|
78
|
+
assertSafePathSegment(key);
|
|
79
|
+
if (typeof node !== "object" || node === null) return undefined;
|
|
80
|
+
node = Object.hasOwn(node, key)
|
|
81
|
+
? (node as Record<string, unknown>)[key]
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function diffKeys(newNode: unknown, oldNode: unknown): string[] {
|
|
88
|
+
const newKeys =
|
|
89
|
+
typeof newNode === "object" && newNode !== null ? Object.keys(newNode) : [];
|
|
90
|
+
const oldKeys =
|
|
91
|
+
typeof oldNode === "object" && oldNode !== null ? Object.keys(oldNode) : [];
|
|
92
|
+
if (oldKeys.length === 0) return newKeys;
|
|
93
|
+
const newSet = new Set(newKeys);
|
|
94
|
+
const deletedKeys = oldKeys.filter((k) => !newSet.has(k)).reverse();
|
|
95
|
+
return [...newKeys, ...deletedKeys];
|
|
96
|
+
}
|