@dxos/assistant 0.7.4 → 0.7.5-labs.071a3e2

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 (115) hide show
  1. package/dist/lib/browser/chunk-AXUUNA7O.mjs +565 -0
  2. package/dist/lib/browser/chunk-AXUUNA7O.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +1212 -47
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +15 -9
  7. package/dist/lib/browser/testing/index.mjs.map +4 -4
  8. package/dist/lib/node/chunk-QPHGQINW.cjs +577 -0
  9. package/dist/lib/node/chunk-QPHGQINW.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +1212 -54
  11. package/dist/lib/node/index.cjs.map +4 -4
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/testing/index.cjs +21 -14
  14. package/dist/lib/node/testing/index.cjs.map +4 -4
  15. package/dist/lib/node-esm/chunk-GOVFOBIB.mjs +567 -0
  16. package/dist/lib/node-esm/chunk-GOVFOBIB.mjs.map +7 -0
  17. package/dist/lib/node-esm/index.mjs +1212 -47
  18. package/dist/lib/node-esm/index.mjs.map +4 -4
  19. package/dist/lib/node-esm/meta.json +1 -1
  20. package/dist/lib/node-esm/testing/index.mjs +15 -9
  21. package/dist/lib/node-esm/testing/index.mjs.map +4 -4
  22. package/dist/types/src/ai-service/client.d.ts +16 -32
  23. package/dist/types/src/ai-service/client.d.ts.map +1 -1
  24. package/dist/types/src/ai-service/index.d.ts +4 -1
  25. package/dist/types/src/ai-service/index.d.ts.map +1 -1
  26. package/dist/types/src/ai-service/parser.d.ts +136 -0
  27. package/dist/types/src/ai-service/parser.d.ts.map +1 -0
  28. package/dist/types/src/ai-service/stream.d.ts +39 -0
  29. package/dist/types/src/ai-service/stream.d.ts.map +1 -0
  30. package/dist/types/src/ai-service/stream.test.d.ts +2 -0
  31. package/dist/types/src/ai-service/stream.test.d.ts.map +1 -0
  32. package/dist/types/src/ai-service/testing.d.ts +7 -0
  33. package/dist/types/src/ai-service/testing.d.ts.map +1 -0
  34. package/dist/types/src/ai-service/transform.d.ts +27 -0
  35. package/dist/types/src/ai-service/transform.d.ts.map +1 -0
  36. package/dist/types/src/ai-service/types.d.ts +150 -0
  37. package/dist/types/src/ai-service/types.d.ts.map +1 -0
  38. package/dist/types/src/ai-service/util/index.d.ts +2 -0
  39. package/dist/types/src/ai-service/util/index.d.ts.map +1 -0
  40. package/dist/types/src/ai-service/{sse.d.ts → util/sse.d.ts} +5 -0
  41. package/dist/types/src/ai-service/util/sse.d.ts.map +1 -0
  42. package/dist/types/src/conversation/conversation.d.ts +61 -6
  43. package/dist/types/src/conversation/conversation.d.ts.map +1 -1
  44. package/dist/types/src/conversation/index.d.ts +0 -1
  45. package/dist/types/src/conversation/index.d.ts.map +1 -1
  46. package/dist/types/src/conversation/types.d.ts +44 -13
  47. package/dist/types/src/conversation/types.d.ts.map +1 -1
  48. package/dist/types/src/cypher/combinators.d.ts.map +1 -1
  49. package/dist/types/src/cypher/parjs.d.ts +1 -1
  50. package/dist/types/src/cypher/parjs.d.ts.map +1 -1
  51. package/dist/types/src/cypher/schema.d.ts.map +1 -1
  52. package/dist/types/src/testing/artifacts.test.d.ts +1 -0
  53. package/dist/types/src/testing/artifacts.test.d.ts.map +1 -0
  54. package/dist/types/src/testing/data.d.ts +2 -0
  55. package/dist/types/src/testing/data.d.ts.map +1 -0
  56. package/dist/types/src/testing/defs.d.ts +5 -0
  57. package/dist/types/src/testing/defs.d.ts.map +1 -0
  58. package/dist/types/src/testing/index.d.ts +1 -0
  59. package/dist/types/src/testing/index.d.ts.map +1 -1
  60. package/dist/types/src/testing/logger.d.ts +1 -1
  61. package/dist/types/src/testing/logger.d.ts.map +1 -1
  62. package/dist/types/src/testing/query-promts.d.ts +1 -1
  63. package/dist/types/src/testing/query-promts.d.ts.map +1 -1
  64. package/dist/types/src/testing/test-data.d.ts +1 -1
  65. package/dist/types/src/testing/test-data.d.ts.map +1 -1
  66. package/dist/types/src/testing/test-schema.d.ts +4 -4
  67. package/dist/types/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +22 -18
  69. package/src/ai-service/client.test.ts +69 -54
  70. package/src/ai-service/client.ts +29 -184
  71. package/src/ai-service/index.ts +4 -1
  72. package/src/ai-service/parser.ts +338 -0
  73. package/src/ai-service/stream.test.ts +101 -0
  74. package/src/ai-service/stream.ts +116 -0
  75. package/src/ai-service/testing.ts +93 -0
  76. package/src/ai-service/transform.ts +121 -0
  77. package/src/ai-service/types.ts +107 -0
  78. package/src/ai-service/util/index.ts +5 -0
  79. package/src/ai-service/{sse.ts → util/sse.ts} +5 -0
  80. package/src/bin/chat.ts +10 -11
  81. package/src/conversation/conversation.test.ts +16 -17
  82. package/src/conversation/conversation.ts +126 -20
  83. package/src/conversation/index.ts +0 -1
  84. package/src/conversation/types.ts +55 -16
  85. package/src/cypher/combinators.test.ts +4 -4
  86. package/src/cypher/combinators.ts +0 -1
  87. package/src/cypher/execution-plan.test.ts +5 -3
  88. package/src/cypher/parjs-shims.d.ts +4 -0
  89. package/src/cypher/parjs.ts +2 -2
  90. package/src/cypher/query-executor.test.ts +1 -1
  91. package/src/cypher/schema.ts +6 -2
  92. package/src/index.ts +1 -1
  93. package/src/testing/artifacts.test.ts +182 -0
  94. package/src/testing/data.ts +13 -0
  95. package/src/testing/defs.ts +8 -0
  96. package/src/testing/index.ts +1 -0
  97. package/src/testing/logger.ts +3 -2
  98. package/src/testing/query-promts.ts +3 -3
  99. package/src/testing/query.test.ts +11 -10
  100. package/src/testing/test-data.ts +8 -2
  101. package/vendor/parjs-browser.mjs +4 -0
  102. package/vendor/parjs-default.mjs +8 -0
  103. package/dist/lib/browser/chunk-AKP45LQH.mjs +0 -1394
  104. package/dist/lib/browser/chunk-AKP45LQH.mjs.map +0 -7
  105. package/dist/lib/node/chunk-G6CGWEMO.cjs +0 -1418
  106. package/dist/lib/node/chunk-G6CGWEMO.cjs.map +0 -7
  107. package/dist/lib/node-esm/chunk-WUPUPEFB.mjs +0 -1396
  108. package/dist/lib/node-esm/chunk-WUPUPEFB.mjs.map +0 -7
  109. package/dist/types/src/ai-service/schema.d.ts +0 -296
  110. package/dist/types/src/ai-service/schema.d.ts.map +0 -1
  111. package/dist/types/src/ai-service/sse.d.ts.map +0 -1
  112. package/dist/types/src/sanity.test.d.ts +0 -2
  113. package/dist/types/src/sanity.test.d.ts.map +0 -1
  114. package/src/ai-service/schema.ts +0 -260
  115. package/src/sanity.test.ts +0 -9
@@ -2,41 +2,38 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- /* eslint-disable no-console */
6
-
7
5
  import { Schema as S } from '@effect/schema';
8
6
 
9
- import { Trigger } from '@dxos/async';
7
+ import { Message, type Space, type Thread } from '@dxos/artifact';
10
8
  import { invariant } from '@dxos/invariant';
11
- import type { SpaceId } from '@dxos/keys';
12
-
13
- import {
14
- Message,
15
- type GenerateRequest,
16
- type ObjectId,
17
- type ResultStreamEvent,
18
- type Space,
19
- type Thread,
20
- } from './schema';
21
- import { iterSSEMessages } from './sse';
22
- import { log } from '@dxos/log';
9
+ import { type SpaceId } from '@dxos/keys';
23
10
 
24
- export type AIServiceClientParams = {
25
- endpoint: string;
26
- };
11
+ import { createGenerationStream, type GenerationStream } from './stream';
12
+ import { type GenerateRequest } from './types';
27
13
 
14
+ // TODO(burdon): Rename.
28
15
  export interface AIServiceClient {
29
16
  getSpace(spaceId: SpaceId): Promise<Space>;
30
17
  getThread(spaceId: SpaceId, threadId: string): Promise<Thread>;
31
- getMessagesInThread(spaceId: SpaceId, threadId: string): Promise<Message[]>;
32
- insertMessages(messages: Message[]): Promise<void>;
18
+ getMessages(spaceId: SpaceId, threadId: string): Promise<Message[]>;
19
+ appendMessages(messages: Message[]): Promise<void>;
33
20
  updateMessage(spaceId: SpaceId, threadId: string, messageId: string, message: Message): Promise<void>;
34
21
  generate(request: GenerateRequest): Promise<GenerationStream>;
35
22
  }
36
23
 
24
+ export type AIServiceClientParams = {
25
+ endpoint: string;
26
+ };
27
+
28
+ /**
29
+ * Edge GPT client.
30
+ */
31
+ // TODO(burdon): Create mock.
37
32
  export class AIServiceClientImpl implements AIServiceClient {
38
33
  private readonly _endpoint: string;
34
+
39
35
  constructor({ endpoint }: AIServiceClientParams) {
36
+ invariant(endpoint, 'endpoint is required');
40
37
  this._endpoint = endpoint;
41
38
  }
42
39
 
@@ -48,13 +45,14 @@ export class AIServiceClientImpl implements AIServiceClient {
48
45
  throw new Error('Not implemented');
49
46
  }
50
47
 
51
- async getMessagesInThread(spaceId: SpaceId, threadId: string): Promise<Message[]> {
48
+ async getMessages(spaceId: SpaceId, threadId: string): Promise<Message[]> {
52
49
  const res = await fetch(`${this._endpoint}/space/${spaceId}/thread/${threadId}/message`);
53
50
  return S.decodePromise(S.Array(Message).pipe(S.mutable))((await res.json()).data.results);
54
51
  }
55
52
 
56
- async insertMessages(messages: Message[]): Promise<void> {
57
- const res = await fetch(`${this._endpoint}/message`, {
53
+ async appendMessages(messages: Message[]): Promise<void> {
54
+ const url = `${this._endpoint}/message`;
55
+ const res = await fetch(url, {
58
56
  method: 'POST',
59
57
  headers: { 'Content-Type': 'application/json' },
60
58
  body: JSON.stringify({
@@ -63,7 +61,7 @@ export class AIServiceClientImpl implements AIServiceClient {
63
61
  });
64
62
 
65
63
  if (!res.ok) {
66
- throw new Error(await res.text());
64
+ throw new Error(`${await res.text()} [${url}]`);
67
65
  }
68
66
  }
69
67
 
@@ -72,172 +70,19 @@ export class AIServiceClientImpl implements AIServiceClient {
72
70
  throw new Error('Not implemented');
73
71
  }
74
72
 
73
+ /**
74
+ * Open message stream.
75
+ */
75
76
  async generate(request: GenerateRequest): Promise<GenerationStream> {
77
+ const controller = new AbortController();
76
78
  const response = await fetch(`${this._endpoint}/generate`, {
79
+ signal: controller.signal,
77
80
  method: 'POST',
78
81
  headers: { 'Content-Type': 'application/json' },
79
82
  body: JSON.stringify(request),
80
83
  });
81
- invariant(response.body instanceof ReadableStream);
82
-
83
- return GenerationStream.fromSSEResponse(
84
- {
85
- spaceId: request.spaceId,
86
- threadId: request.threadId,
87
- },
88
- response,
89
- );
90
- }
91
- }
92
-
93
- type GenerationParams = {
94
- spaceId: SpaceId;
95
- threadId: ObjectId;
96
- };
97
-
98
- export class GenerationStream implements AsyncIterable<ResultStreamEvent> {
99
- static fromSSEResponse(params: GenerationParams, response: Response) {
100
- const controller = new AbortController();
101
- const iterator = async function* () {
102
- for await (const sse of iterSSEMessages(response, controller)) {
103
- if (sse.event === 'completion') {
104
- try {
105
- yield JSON.parse(sse.data);
106
- } catch (e) {
107
- console.error('Could not parse message into JSON:', sse.data);
108
- console.error('From chunk:', sse.raw);
109
- throw e;
110
- }
111
- }
112
-
113
- if (
114
- sse.event === 'message_start' ||
115
- sse.event === 'message_delta' ||
116
- sse.event === 'message_stop' ||
117
- sse.event === 'content_block_start' ||
118
- sse.event === 'content_block_delta' ||
119
- sse.event === 'content_block_stop'
120
- ) {
121
- try {
122
- yield JSON.parse(sse.data);
123
- } catch (e) {
124
- console.error('Could not parse message into JSON:', sse.data);
125
- console.error('From chunk:', sse.raw);
126
- throw e;
127
- }
128
- }
129
84
 
130
- if (sse.event === 'ping') {
131
- continue;
132
- }
133
-
134
- if (sse.event === 'error') {
135
- throw new Error(`Message generation error: ${sse.data}`);
136
- }
137
- }
138
- };
139
- return new GenerationStream(params, iterator);
140
- }
141
-
142
- private _previousMessages: Message[] = [];
143
- private _accumulatedMessage?: Message = undefined;
144
- private _done = new Trigger();
145
- private _iterator?: AsyncIterator<ResultStreamEvent> = undefined;
146
-
147
- constructor(
148
- private readonly _params: GenerationParams,
149
- private readonly _getIterator: () => AsyncIterableIterator<ResultStreamEvent>,
150
- ) {}
151
-
152
- [Symbol.asyncIterator](): AsyncIterator<ResultStreamEvent> {
153
- return this._createIterator();
154
- }
155
-
156
- cancel() {
157
- throw new Error('Not implemented');
158
- }
159
-
160
- get accumulatedMessages(): Message[] {
161
- // TODO(dmaretskyi): Support multiple accumulated messages.
162
- return [...this._previousMessages, ...(this._accumulatedMessage == null ? [] : [this._accumulatedMessage])];
163
- }
164
-
165
- /**
166
- * Returns the complete message list generated by the AI service.
167
- */
168
- async complete(): Promise<Message[]> {
169
- await this._done.wait();
170
- return this.accumulatedMessages;
171
- }
172
-
173
- private _createIterator(): AsyncIterator<ResultStreamEvent> {
174
- const self = this;
175
- return (this._iterator ??= (async function* () {
176
- for await (const event of self._getIterator()) {
177
- self._processEvent(event);
178
- yield event;
179
- }
180
- })());
181
- }
182
-
183
- private _processEvent(event: ResultStreamEvent) {
184
- // TODO(dmaretskyi): Support multiple message, e.g. for service-defined tools.
185
- switch (event.type) {
186
- case 'message_start': {
187
- if (this._accumulatedMessage) {
188
- this._previousMessages.push(this._accumulatedMessage);
189
- this._accumulatedMessage = undefined;
190
- }
191
- this._accumulatedMessage = {
192
- ...event.message,
193
- threadId: this._params.threadId,
194
- spaceId: this._params.spaceId,
195
- };
196
- break;
197
- }
198
- case 'message_delta': {
199
- if (this._accumulatedMessage === undefined) {
200
- throw new Error('Received message delta without a message start');
201
- }
202
- break;
203
- }
204
- case 'message_stop': {
205
- if (this._accumulatedMessage === undefined) {
206
- throw new Error('Received message stop without a message start');
207
- }
208
-
209
- break;
210
- }
211
- case 'content_block_start': {
212
- invariant(this._accumulatedMessage);
213
- this._accumulatedMessage.content.push(event.content);
214
- break;
215
- }
216
- case 'content_block_delta': {
217
- invariant(this._accumulatedMessage);
218
- const snapshotContent = this._accumulatedMessage.content.at(event.index);
219
- if (snapshotContent?.type === 'text' && event.delta.type === 'text_delta') {
220
- snapshotContent.text += event.delta.text;
221
- } else if (snapshotContent?.type === 'tool_use' && event.delta.type === 'input_json_delta') {
222
- snapshotContent.inputJson ??= '';
223
- snapshotContent.inputJson += event.delta.partial_json;
224
-
225
- // TODO(dmaretskyi): Partial parsing.
226
- // if (jsonBuf) {
227
- // snapshotContent.input = partialParse(jsonBuf);
228
- // }
229
- }
230
- break;
231
- }
232
- case 'content_block_stop': {
233
- invariant(this._accumulatedMessage);
234
- const lastBlock = this._accumulatedMessage.content.at(-1);
235
- if (lastBlock?.type === 'tool_use') {
236
- lastBlock.input = JSON.parse(lastBlock.inputJson ?? '');
237
- }
238
- break;
239
- }
240
- }
241
- this._done.wake();
85
+ invariant(response.body instanceof ReadableStream);
86
+ return createGenerationStream(response, controller);
242
87
  }
243
88
  }
@@ -3,4 +3,7 @@
3
3
  //
4
4
 
5
5
  export * from './client';
6
- export * from './schema';
6
+ export * from './parser';
7
+ export * from './stream';
8
+ export * from './transform';
9
+ export * from './types';
@@ -0,0 +1,338 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Message, type MessageContentBlock } from '@dxos/artifact';
6
+ import { Event } from '@dxos/async';
7
+ import { createStatic } from '@dxos/echo-schema';
8
+ import { invariant } from '@dxos/invariant';
9
+ import { log } from '@dxos/log';
10
+ import { isNotFalsy, safeParseJson } from '@dxos/util';
11
+
12
+ import { type GenerationStream } from './stream';
13
+ import { StreamTransform, type StreamBlock } from './transform';
14
+
15
+ /**
16
+ * Parse mixed content of plain text, XML fragments, and JSON blocks.
17
+ */
18
+ export class MixedStreamParser {
19
+ /**
20
+ * New message.
21
+ */
22
+ public message = new Event<Message>();
23
+
24
+ /**
25
+ * Complete block added to Message.
26
+ */
27
+ public block = new Event<MessageContentBlock>();
28
+
29
+ /**
30
+ * Update partial block (while streaming).
31
+ */
32
+ public update = new Event<MessageContentBlock>();
33
+
34
+ /**
35
+ * Current message.
36
+ */
37
+ private _message?: Message | undefined;
38
+
39
+ private _emitBlock(contentBlock: MessageContentBlock, streamBlock?: StreamBlock) {
40
+ const messageBlock = streamBlock ? mergeMessageBlock(contentBlock, streamBlock) : contentBlock;
41
+ if (messageBlock) {
42
+ if (messageBlock.type === 'text' && messageBlock.text.length === 0) {
43
+ return;
44
+ }
45
+
46
+ invariant(this._message);
47
+ this._message.content.push(messageBlock);
48
+ this.block.emit(messageBlock);
49
+ }
50
+ }
51
+
52
+ private _emitUpdate(contentBlock: MessageContentBlock, streamBlock: StreamBlock) {
53
+ const messageBlock = mergeMessageBlock(contentBlock, streamBlock);
54
+ if (messageBlock) {
55
+ messageBlock.pending = true;
56
+ this.update.emit(messageBlock);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Parse stream until end.
62
+ */
63
+ async parse(stream: GenerationStream) {
64
+ const transformer = new StreamTransform();
65
+
66
+ /**
67
+ * Current content message block.
68
+ */
69
+ let contentBlock: MessageContentBlock | undefined;
70
+
71
+ /**
72
+ * Current partial block used to accumulate content.
73
+ */
74
+ let streamBlock: StreamBlock | undefined;
75
+ const stack: StreamBlock[] = [];
76
+
77
+ for await (const event of stream) {
78
+ // log.info('event', { type: event.type, event });
79
+ switch (event.type) {
80
+ //
81
+ // Messages.
82
+ //
83
+
84
+ case 'message_start': {
85
+ if (this._message) {
86
+ log.warn('unexpected message_start');
87
+ }
88
+
89
+ this._message = createStatic(Message, { role: 'assistant', content: [] });
90
+ this.message.emit(this._message);
91
+ break;
92
+ }
93
+
94
+ case 'message_delta': {
95
+ if (!this._message) {
96
+ log.warn('unexpected message_delta');
97
+ }
98
+
99
+ break;
100
+ }
101
+
102
+ case 'message_stop': {
103
+ if (!this._message) {
104
+ log.warn('unexpected message_stop');
105
+ } else {
106
+ this._message = undefined;
107
+ }
108
+ break;
109
+ }
110
+
111
+ //
112
+ // Content blocks.
113
+ //
114
+
115
+ case 'content_block_start': {
116
+ if (contentBlock) {
117
+ log.warn('unexpected content_block_start', { content: contentBlock });
118
+ }
119
+
120
+ // TODO(burdon): Parse initial content (factor out from content_block_delta).
121
+ contentBlock = event.content;
122
+ break;
123
+ }
124
+
125
+ case 'content_block_delta': {
126
+ if (!contentBlock) {
127
+ log.warn('unexpected content_block_delta');
128
+ break;
129
+ }
130
+
131
+ switch (event.delta.type) {
132
+ //
133
+ // Text
134
+ //
135
+ case 'text_delta': {
136
+ const chunks = transformer.transform(event.delta.text);
137
+ for (const chunk of chunks) {
138
+ // log.info('text_delta', { chunk, current });
139
+
140
+ switch (streamBlock?.type) {
141
+ //
142
+ // XML Fragment.
143
+ //
144
+ case 'tag': {
145
+ if (chunk.type === 'tag') {
146
+ if (chunk.selfClosing) {
147
+ streamBlock.content.push(chunk);
148
+ } else if (chunk.closing) {
149
+ if (stack.length > 0) {
150
+ const top = stack.pop();
151
+ invariant(top && top.type === 'tag');
152
+ top.content.push(streamBlock);
153
+ streamBlock = top;
154
+ } else {
155
+ this._emitBlock(contentBlock, streamBlock);
156
+ streamBlock = undefined;
157
+ }
158
+ } else {
159
+ stack.push(streamBlock);
160
+ streamBlock = chunk;
161
+ }
162
+ } else {
163
+ // Append text.
164
+ if (streamBlock.content.length === 0) {
165
+ streamBlock.content.push(chunk);
166
+ } else {
167
+ const last = streamBlock.content.at(-1);
168
+ invariant(last);
169
+ if (last.type === 'text') {
170
+ last.content += chunk.content;
171
+ } else {
172
+ streamBlock.content.push(chunk);
173
+ }
174
+ }
175
+ }
176
+ break;
177
+ }
178
+
179
+ //
180
+ // Text Fragment.
181
+ //
182
+ case 'text': {
183
+ if (chunk.type === 'tag') {
184
+ this._emitBlock(contentBlock, streamBlock);
185
+ if (chunk.selfClosing) {
186
+ this._emitBlock(contentBlock, chunk);
187
+ streamBlock = undefined;
188
+ } else {
189
+ streamBlock = chunk;
190
+ }
191
+ } else {
192
+ // Append text.
193
+ streamBlock.content += chunk.content;
194
+ }
195
+ break;
196
+ }
197
+
198
+ //
199
+ // No current chunk.
200
+ //
201
+ default: {
202
+ if (chunk.type === 'tag' && chunk.selfClosing) {
203
+ this._emitBlock(contentBlock, chunk);
204
+ } else {
205
+ streamBlock = chunk;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ break;
211
+ }
212
+
213
+ //
214
+ // JSON
215
+ //
216
+ case 'input_json_delta': {
217
+ if (!contentBlock) {
218
+ log.warn('unexpected input_json_delta', { contentBlock });
219
+ break;
220
+ }
221
+
222
+ if (streamBlock?.type === 'json') {
223
+ streamBlock.content += event.delta.partial_json;
224
+ } else {
225
+ streamBlock = { type: 'json', content: event.delta.partial_json };
226
+ }
227
+ break;
228
+ }
229
+ }
230
+ break;
231
+ }
232
+
233
+ case 'content_block_stop': {
234
+ if (!contentBlock) {
235
+ log.warn('unexpected content_block_stop');
236
+ break;
237
+ }
238
+
239
+ this._emitBlock(contentBlock, streamBlock);
240
+ contentBlock = undefined;
241
+ streamBlock = undefined;
242
+ break;
243
+ }
244
+ }
245
+
246
+ if (contentBlock && streamBlock) {
247
+ this._emitUpdate(contentBlock, streamBlock);
248
+ }
249
+ } // for.
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Convert stream block to message content block.
255
+ */
256
+ export const mergeMessageBlock = (
257
+ contentBlock: MessageContentBlock,
258
+ streamBlock: StreamBlock,
259
+ ): MessageContentBlock | undefined => {
260
+ log('mergeMessageBlock', { contentBlock, streamBlock });
261
+
262
+ switch (streamBlock.type) {
263
+ //
264
+ // Text
265
+ //
266
+ case 'text': {
267
+ return { ...contentBlock, type: 'text', text: streamBlock.content };
268
+ }
269
+
270
+ //
271
+ // XML
272
+ //
273
+ case 'tag': {
274
+ switch (streamBlock.tag) {
275
+ case 'cot': {
276
+ const content = streamBlock.content
277
+ .map((block) => {
278
+ switch (block.type) {
279
+ case 'text':
280
+ return block.content;
281
+ default:
282
+ return null;
283
+ }
284
+ })
285
+ .filter(isNotFalsy)
286
+ .join('\n');
287
+
288
+ return { ...contentBlock, type: 'text', disposition: 'cot', text: content };
289
+ }
290
+
291
+ case 'artifact': {
292
+ const { attributes } = streamBlock;
293
+ return { type: 'json', disposition: 'artifact', json: JSON.stringify(attributes) };
294
+ }
295
+
296
+ case 'suggest': {
297
+ if (streamBlock.content.length === 1 && streamBlock.content[0].type === 'text') {
298
+ return {
299
+ type: 'json',
300
+ disposition: 'suggest',
301
+ json: JSON.stringify({ text: streamBlock.content[0].content }),
302
+ };
303
+ }
304
+ break;
305
+ }
306
+
307
+ case 'select': {
308
+ return {
309
+ type: 'json',
310
+ disposition: 'select',
311
+ json: JSON.stringify({
312
+ options: streamBlock.content.flatMap((content) =>
313
+ content.type === 'tag' && content.content.length === 1 && content.content[0].type === 'text'
314
+ ? [content.content[0].content]
315
+ : [],
316
+ ),
317
+ }),
318
+ };
319
+ }
320
+ }
321
+
322
+ break;
323
+ }
324
+
325
+ //
326
+ // JSON
327
+ //
328
+ case 'json': {
329
+ switch (contentBlock.type) {
330
+ case 'tool_use': {
331
+ return { ...contentBlock, input: safeParseJson(streamBlock.content) ?? {} };
332
+ }
333
+ }
334
+
335
+ return { ...contentBlock, type: 'json', disposition: 'artifact', json: streamBlock.content };
336
+ }
337
+ }
338
+ };
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, it } from 'vitest';
6
+
7
+ import { type Message } from '@dxos/artifact';
8
+ import { log } from '@dxos/log';
9
+
10
+ import { MixedStreamParser } from './parser';
11
+ import { createGenerationStream } from './stream';
12
+ import { createTestSSEStream } from './testing';
13
+ import { type GenerationStreamEvent } from './types';
14
+
15
+ // TODO(burdon): Tool request/response.
16
+
17
+ const TEST_BLOCKS = [
18
+ [
19
+ // Text
20
+ 'some text',
21
+ 'on multiple lines.',
22
+
23
+ // XML
24
+ '<cot>',
25
+ ' 1. Analyze content.',
26
+ ' 2. Create plan.',
27
+ ' 3. Generate artifacts.',
28
+ '</cot>',
29
+
30
+ // Text
31
+ 'some more text.',
32
+
33
+ // XML
34
+ '<artifact id="123" />',
35
+
36
+ // XML
37
+ '<artifact id="456" />',
38
+
39
+ // Text
40
+ 'and some more text.',
41
+
42
+ '<suggest>Plan me a trip to the Rio.</suggest>',
43
+ '<suggest>Show me steak recipes.</suggest>',
44
+ '<select><option>Yes</option><option>No</option></select>',
45
+ ].join('\n'),
46
+
47
+ // JSON
48
+ {
49
+ type: 'input',
50
+ value: {
51
+ level: 1,
52
+ optional: true,
53
+ },
54
+ },
55
+ ];
56
+
57
+ describe('GenerationStream', () => {
58
+ it('should generate a stream of blocks', async ({ expect }) => {
59
+ const stream = createGenerationStream(new Response(createTestSSEStream(TEST_BLOCKS)));
60
+ const events: GenerationStreamEvent[] = [];
61
+ for await (const event of stream) {
62
+ events.push(event);
63
+ }
64
+
65
+ expect(events.map((event) => event.type === 'content_block_start').filter(Boolean)).to.have.length(2);
66
+ });
67
+
68
+ for (const splitBy of ['word'] as const) {
69
+ it(`should emit xml blocks (splitBy: ${splitBy})`, async ({ expect }) => {
70
+ const stream = createGenerationStream(new Response(createTestSSEStream(TEST_BLOCKS, { splitBy })));
71
+ const parser = new MixedStreamParser();
72
+
73
+ let message: Message | undefined;
74
+ parser.message.on((_message) => {
75
+ message = _message;
76
+ });
77
+
78
+ await parser.parse(stream);
79
+
80
+ for (const block of message?.content ?? []) {
81
+ log.info('block', { block });
82
+ }
83
+ expect(message?.content.map((block) => block.type)).to.deep.eq([
84
+ //
85
+ 'text',
86
+ 'text',
87
+ 'text',
88
+ 'json',
89
+ 'text',
90
+ 'json',
91
+ 'text',
92
+ 'json',
93
+ 'text',
94
+ 'json',
95
+ 'text',
96
+ 'json',
97
+ 'json',
98
+ ]);
99
+ });
100
+ }
101
+ });