@adecore/agents 0.20.1 → 0.21.0

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.
@@ -1,5 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { notResumedNote } from '@adecore/agent-contracts';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
3
+ import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
4
+ import { UiStream } from '@adecore/intelligent-ui/stream';
3
5
  import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
4
6
  import { ChatError } from './errors.js';
5
7
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
@@ -70,12 +72,30 @@ export class ChatSession {
70
72
  pendingPreambles;
71
73
  submission = null;
72
74
  steering = new Set();
75
+ uiStreams = new Map();
76
+ uiCompiled = new Map();
73
77
  constructor(options) {
74
78
  this.options = options;
75
79
  this.pendingPreambles = options.preambles ?? [];
76
80
  this.thread = new ChatThread(options.info, options.items);
77
81
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
78
82
  }
83
+ get uiPreviews() {
84
+ const events = [];
85
+ for (const [itemId, preview] of this.uiCompiled) {
86
+ const item = this.thread.get(itemId);
87
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
88
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
89
+ }
90
+ }
91
+ return events;
92
+ }
93
+ previewItems(items) {
94
+ return items.map((item) => {
95
+ const preview = this.uiCompiled.get(item.id);
96
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
97
+ });
98
+ }
79
99
  get id() {
80
100
  return this.thread.info.chatId;
81
101
  }
@@ -148,6 +168,7 @@ export class ChatSession {
148
168
  turnId,
149
169
  text,
150
170
  createdAt: Date.now(),
171
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
151
172
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
152
173
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
153
174
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
@@ -161,6 +182,76 @@ export class ChatSession {
161
182
  this.dispatch(text, extras, turnId);
162
183
  return { queued: false, turnId };
163
184
  }
185
+ updateUiQueries(itemId, uiQueries) {
186
+ const item = this.thread.get(itemId);
187
+ if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
188
+ return;
189
+ }
190
+ this.thread.upsert({ ...item, uiQueries });
191
+ this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
192
+ this.options.persistSoon();
193
+ }
194
+ choose(payload, queries = {}) {
195
+ const item = this.thread.get(payload.itemId);
196
+ const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
197
+ if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
198
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
199
+ }
200
+ const sameBlock = (origin) => origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
201
+ let previous = item.uiAnswers?.[block.id];
202
+ if (previous?.revision !== block.revision) {
203
+ previous = undefined;
204
+ }
205
+ // The message is logged first: a crash before its answer marker must not send it a second time.
206
+ if (!previous) {
207
+ const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
208
+ const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
209
+ const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
210
+ const turnId = sent?.turnId ?? queued?.turnId;
211
+ if (origin && turnId) {
212
+ previous = { ...origin, turnId, queued: !sent };
213
+ this.recordUiAnswer(previous);
214
+ }
215
+ }
216
+ if (previous) {
217
+ if (previous.choiceId !== payload.choiceId) {
218
+ throw new ChatError('ui-already-answered', 'This UI block was already answered');
219
+ }
220
+ return { queued: previous.queued, turnId: previous.turnId };
221
+ }
222
+ let selected;
223
+ try {
224
+ selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
225
+ }
226
+ catch (error) {
227
+ throw new ChatError(error instanceof UiFailure ? error.code : 'refused-choice', error instanceof Error ? error.message : 'This choice could not be read');
228
+ }
229
+ const origin = {
230
+ itemId: item.id,
231
+ blockId: block.id,
232
+ revision: block.revision,
233
+ choiceId: payload.choiceId,
234
+ label: selected.label,
235
+ sourceAt: item.createdAt,
236
+ older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
237
+ values: selected.values,
238
+ at: Date.now()
239
+ };
240
+ const result = this.send(selected.context, { uiChoice: origin });
241
+ this.recordUiAnswer({ ...origin, ...result });
242
+ return result;
243
+ }
244
+ recordUiAnswer(answer) {
245
+ const item = this.thread.get(answer.itemId);
246
+ if (item?.kind !== 'assistant') {
247
+ return;
248
+ }
249
+ if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
250
+ return;
251
+ }
252
+ this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
253
+ this.options.persist();
254
+ }
164
255
  sendInput(text, extras = {}) {
165
256
  return this.submitInput(text, extras);
166
257
  }
@@ -289,7 +380,7 @@ export class ChatSession {
289
380
  this.sendAfterReplacement = false;
290
381
  this.pauseQueue(false);
291
382
  this.setQueue(rest);
292
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
383
+ this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
293
384
  }
294
385
  dispatch(text, extras, requestedTurnId) {
295
386
  this.pauseQueue(false);
@@ -298,7 +389,10 @@ export class ChatSession {
298
389
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
299
390
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
300
391
  const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
301
- const said = [preamble, references.note].filter((part) => part !== null);
392
+ const choiceNote = extras.uiChoice
393
+ ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
394
+ : null;
395
+ const said = [preamble, references.note, choiceNote].filter((part) => part !== null);
302
396
  const input = {
303
397
  text,
304
398
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -410,14 +504,14 @@ export class ChatSession {
410
504
  }
411
505
  /*
412
506
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
413
- * A turn in the way is refused unless forced, and a forced clear does not wait for it to end,
414
- * since the turn disappears with the thread anyway. Writing the empty thread is the caller's.
507
+ * The thread resets at once; the returned promise lets the host wait for pending attachment
508
+ * writes before removing their files. Writing the empty thread is the caller's.
415
509
  */
416
510
  clear(force) {
417
511
  if (this.busy && !force) {
418
512
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
419
513
  }
420
- void this.dispose();
514
+ const ended = this.dispose();
421
515
  this.generation = nextGeneration();
422
516
  this.projector.reset();
423
517
  // The fresh CLI hears what the host tells it at launch, as it would on a first turn.
@@ -456,6 +550,7 @@ export class ChatSession {
456
550
  }
457
551
  })
458
552
  ]);
553
+ return ended;
459
554
  }
460
555
  /*
461
556
  * A thread read from disk cannot still be streaming or waiting, so whatever was open is closed,
@@ -836,6 +931,7 @@ export class ChatSession {
836
931
  }
837
932
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
838
933
  freeze() {
934
+ this.clearUi();
839
935
  this.clearStartWarning();
840
936
  this.clearWorkflowWarnings();
841
937
  this.frozen = true;
@@ -843,6 +939,7 @@ export class ChatSession {
843
939
  }
844
940
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
845
941
  dispose() {
942
+ this.clearUi();
846
943
  this.clearStartWarning();
847
944
  this.clearWorkflowWarnings();
848
945
  if (this.titleTimer !== null) {
@@ -988,10 +1085,24 @@ export class ChatSession {
988
1085
  const skills = extras.skills?.length ? extras.skills : undefined;
989
1086
  const chats = extras.chats?.length ? extras.chats : undefined;
990
1087
  const attachments = extras.attachments?.length ? extras.attachments : undefined;
991
- events.push(this.thread.upsert({ id: newId('user'), kind: 'user', createdAt: now, turnId, text, mentions, skills, chats, attachments }));
1088
+ events.push(this.thread.upsert({
1089
+ id: newId('user'),
1090
+ kind: 'user',
1091
+ createdAt: now,
1092
+ turnId,
1093
+ text,
1094
+ mentions,
1095
+ skills,
1096
+ chats,
1097
+ attachments,
1098
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1099
+ }));
992
1100
  }
993
1101
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
994
1102
  this.emit(events);
1103
+ if (extras.uiChoice) {
1104
+ this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1105
+ }
995
1106
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
996
1107
  this.options.persist();
997
1108
  return turnId;
@@ -1206,6 +1317,7 @@ export class ChatSession {
1206
1317
  runtimeMode: info.runtimeMode,
1207
1318
  resume: info.agentSessionId,
1208
1319
  generation,
1320
+ saveGeneratedImage: this.options.saveGeneratedImage,
1209
1321
  instructions: this.options.instructions?.() ?? null,
1210
1322
  resumeNote: this.options.resumeNote?.() ?? null,
1211
1323
  ...(folders.length > 0 ? { folders } : {}),
@@ -1536,9 +1648,77 @@ export class ChatSession {
1536
1648
  }
1537
1649
  emit(events) {
1538
1650
  const delegating = this.delegatingChange(events);
1539
- for (const event of delegating === null ? events : [...events, delegating]) {
1540
- this.options.emit(event);
1651
+ for (let event of delegating === null ? events : [...events, delegating]) {
1652
+ if (event.type === 'reset') {
1653
+ this.clearUi();
1654
+ }
1655
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1656
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
1657
+ this.options.emit(event);
1658
+ continue;
1659
+ }
1660
+ let stream = this.uiStreams.get(item.id);
1661
+ if (!stream && !item.text.includes('ruimte-ui')) {
1662
+ this.options.emit(event);
1663
+ continue;
1664
+ }
1665
+ if (!stream) {
1666
+ stream = new UiStream({
1667
+ querySchemas: this.options.ui?.schemas,
1668
+ id: item.id,
1669
+ clock: this.options.clock ?? systemClock,
1670
+ emit: (preview) => {
1671
+ this.uiCompiled.set(item.id, preview);
1672
+ this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1673
+ }
1674
+ });
1675
+ this.uiStreams.set(item.id, stream);
1676
+ }
1677
+ this.options.ui?.observe(item, false);
1678
+ const latestAttachment = this.latestUiAttachment(item.turnId);
1679
+ if (event.type === 'item' && !item.streaming) {
1680
+ const ui = stream
1681
+ .finish(item.text, latestAttachment)
1682
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1683
+ this.uiStreams.delete(item.id);
1684
+ this.uiCompiled.delete(item.id);
1685
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1686
+ if (diagnostics.length > 0) {
1687
+ const repair = `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
1688
+ if (!this.pendingPreambles.includes(repair)) {
1689
+ this.pendingPreambles = [...this.pendingPreambles, repair];
1690
+ }
1691
+ }
1692
+ const current = this.thread.get(item.id);
1693
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1694
+ event = this.thread.upsert(completed);
1695
+ this.options.emit(event);
1696
+ this.options.ui?.observe(completed, true);
1697
+ }
1698
+ else {
1699
+ this.options.emit(event);
1700
+ stream.update(item.text, latestAttachment);
1701
+ }
1702
+ }
1703
+ }
1704
+ latestUiAttachment(turnId) {
1705
+ for (const item of this.thread.list().toReversed()) {
1706
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1707
+ continue;
1708
+ }
1709
+ const input = GeneratedImageInputSchema.safeParse(item.input);
1710
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
1711
+ return input.data.attachment.id;
1712
+ }
1713
+ }
1714
+ return undefined;
1715
+ }
1716
+ clearUi() {
1717
+ for (const stream of this.uiStreams.values()) {
1718
+ stream.dispose();
1541
1719
  }
1720
+ this.uiStreams.clear();
1721
+ this.uiCompiled.clear();
1542
1722
  }
1543
1723
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
1544
1724
  delegatingChange(events) {
@@ -9,6 +9,7 @@ export declare class CodexBackend implements ChatBackend {
9
9
  readonly acknowledgesTurns = true;
10
10
  private promptId;
11
11
  private readonly launch;
12
+ private imageWrites;
12
13
  private readonly host;
13
14
  private readonly protocol;
14
15
  private readonly client;
@@ -50,4 +51,5 @@ export declare class CodexBackend implements ChatBackend {
50
51
  private handleFrame;
51
52
  private handleExit;
52
53
  private emit;
54
+ private storeImage;
53
55
  }
@@ -39,6 +39,7 @@ export class CodexBackend {
39
39
  acknowledgesTurns = true;
40
40
  promptId = null;
41
41
  launch;
42
+ imageWrites = null;
42
43
  host;
43
44
  protocol;
44
45
  client;
@@ -321,7 +322,7 @@ export class CodexBackend {
321
322
  dispose() {
322
323
  const transport = this.transport;
323
324
  this.transport = null;
324
- return transport?.dispose() ?? Promise.resolve();
325
+ return Promise.all([transport?.dispose(), this.imageWrites]).then(() => undefined);
325
326
  }
326
327
  /* A request whose failure the person has to hear about, since it is the turn that cannot go on. */
327
328
  request(method, params, accepted) {
@@ -381,6 +382,55 @@ export class CodexBackend {
381
382
  this.interruptOwed = false;
382
383
  this.promptId = null;
383
384
  }
385
+ if (event.type === 'image.generated' || this.imageWrites !== null) {
386
+ const pending = (this.imageWrites ?? Promise.resolve()).then(async () => {
387
+ if (event.type === 'image.generated') {
388
+ await this.storeImage(event);
389
+ }
390
+ else {
391
+ this.host.onEvent(event);
392
+ }
393
+ });
394
+ this.imageWrites = pending;
395
+ void pending
396
+ .finally(() => {
397
+ if (this.imageWrites === pending) {
398
+ this.imageWrites = null;
399
+ }
400
+ })
401
+ .catch(() => undefined);
402
+ return;
403
+ }
384
404
  this.host.onEvent(event);
385
405
  }
406
+ async storeImage(event) {
407
+ const input = { revisedPrompt: event.revisedPrompt, transparentBackground: event.transparentBackground };
408
+ let attachment;
409
+ try {
410
+ if (!this.launch.saveGeneratedImage) {
411
+ throw new Error('This machine cannot save generated images');
412
+ }
413
+ attachment = await this.launch.saveGeneratedImage(event.ref, event.upload);
414
+ }
415
+ catch (error) {
416
+ this.host.onEvent({ type: 'tool.started', ref: event.ref, name: 'ImageGeneration', input, parentRef: null });
417
+ this.host.onEvent({ type: 'tool.done', ref: event.ref, output: errorText(error), state: 'error' });
418
+ return;
419
+ }
420
+ this.host.onEvent({
421
+ type: 'tool.started',
422
+ ref: event.ref,
423
+ name: 'ImageGeneration',
424
+ input: {
425
+ ...input,
426
+ attachment: {
427
+ ...attachment,
428
+ ...(event.width === undefined ? {} : { width: event.width }),
429
+ ...(event.height === undefined ? {} : { height: event.height })
430
+ }
431
+ },
432
+ parentRef: null
433
+ });
434
+ this.host.onEvent({ type: 'tool.done', ref: event.ref, output: null, state: 'done' });
435
+ }
386
436
  }
@@ -1,3 +1,4 @@
1
+ import { generatedImage } from './generated-image.js';
1
2
  import { readCodexLimits } from '../usage/limits/normalize.js';
2
3
  import { codexRuntimeMode } from '../providers/codex.js';
3
4
  import { cleanTitle } from '../title-file.js';
@@ -567,7 +568,7 @@ export class CodexProtocol {
567
568
  events.push({ type: 'question.requested', requestId: ref, questions, async: true });
568
569
  return;
569
570
  }
570
- this.text(ref, str(item.text) ?? '', completed, events);
571
+ this.text(ref, str(item.text) ?? '', completed, events, str(item.phase) ?? undefined);
571
572
  return;
572
573
  }
573
574
  case 'plan':
@@ -616,6 +617,32 @@ export class CodexProtocol {
616
617
  case 'dynamicToolCall':
617
618
  this.tool(ref, str(item.tool) ?? 'tool', item.arguments ?? {}, completed, textOf(item.contentItems), item.success !== false, events);
618
619
  return;
620
+ case 'imageGeneration': {
621
+ const revisedPrompt = str(item.revisedPrompt) ?? undefined;
622
+ const transparentBackground = typeof item.transparentBackground === 'boolean' ? item.transparentBackground : undefined;
623
+ const input = {
624
+ ...(revisedPrompt === undefined ? {} : { revisedPrompt }),
625
+ ...(transparentBackground === undefined ? {} : { transparentBackground })
626
+ };
627
+ if (!completed) {
628
+ this.tool(ref, 'ImageGeneration', input, false, '', true, events);
629
+ return;
630
+ }
631
+ try {
632
+ if (item.failure || item.status !== 'completed') {
633
+ throw new Error(isRecord(item.failure) ? (str(item.failure.message) ?? 'Image generation failed') : (str(item.failure) ?? 'Image generation failed'));
634
+ }
635
+ const image = generatedImage(str(item.result) ?? '', revisedPrompt);
636
+ events.push({ type: 'image.generated', ref, ...image, ...input });
637
+ }
638
+ catch (error) {
639
+ this.tool(ref, 'ImageGeneration', input, true, error instanceof Error ? error.message : 'Image generation failed', false, events);
640
+ }
641
+ return;
642
+ }
643
+ case 'imageView':
644
+ this.tool(ref, 'ImageView', { path: str(item.path) ?? '' }, completed, '', true, events);
645
+ return;
619
646
  case 'webSearch': {
620
647
  const action = isRecord(item.action) ? item.action : {};
621
648
  this.tool(ref, 'WebSearch', { query: str(item.query) ?? str(action.query) ?? '' }, completed, '', true, events);
@@ -683,8 +710,8 @@ export class CodexProtocol {
683
710
  }
684
711
  return null;
685
712
  }
686
- text(ref, text, completed, events) {
687
- events.push(completed ? { type: 'text.done', ref, text } : { type: 'text.delta', ref, text });
713
+ text(ref, text, completed, events, phase) {
714
+ events.push({ type: completed ? 'text.done' : 'text.delta', ref, text, ...(phase === undefined ? {} : { phase }) });
688
715
  }
689
716
  /*
690
717
  * An agent Codex spawned, as a spawnAgent call reports it. It works in a thread of its own, so this row
@@ -16,7 +16,7 @@ export class DeltaCoalescer {
16
16
  this.tickMs = tickMs;
17
17
  }
18
18
  push(event) {
19
- if (event.type !== 'delta') {
19
+ if (event.type !== 'delta' || event.uiQueries !== undefined) {
20
20
  this.flush();
21
21
  this.send(event);
22
22
  return;
@@ -1,4 +1,4 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
- export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
2
+ export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
3
3
  export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
4
4
  }
@@ -169,6 +169,39 @@ export function fakeCodexWith(options) {
169
169
  turnCompleted('failed', "You've hit your usage limit.", 'usageLimitExceeded');
170
170
  return;
171
171
  }
172
+ if (text.startsWith('image:')) {
173
+ const scenario = text.slice(6);
174
+ const entry = item('imageGeneration', {
175
+ status: 'inProgress',
176
+ result: '',
177
+ savedPath: '/never-read-generated-image.png',
178
+ revisedPrompt: 'A rabbit',
179
+ transparentBackground: true
180
+ });
181
+ started(entry);
182
+ const result = scenario === 'empty'
183
+ ? ''
184
+ : scenario === 'invalid'
185
+ ? 'not base64'
186
+ : scenario === 'not-image'
187
+ ? Buffer.from('text').toString('base64')
188
+ : scenario === 'too-large'
189
+ ? Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64')
190
+ : 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
191
+ const done = {
192
+ ...entry,
193
+ status: scenario === 'failed' ? 'failed' : 'completed',
194
+ result,
195
+ ...(scenario === 'failed' ? { failure: { message: 'Image generation failed' } } : {})
196
+ };
197
+ completed(done);
198
+ if (scenario === 'replay') {
199
+ completed(done);
200
+ }
201
+ agentMessage('Here is the generated image.');
202
+ turnCompleted('completed');
203
+ return;
204
+ }
172
205
  if (text === 'slow') {
173
206
  slow = true;
174
207
  started(item('agentMessage', { text: '', phase: 'final_answer', memoryCitation: null, delivery: null, questions: null }));
@@ -0,0 +1,6 @@
1
+ import { type ChatAttachmentUpload } from '@adecore/agent-contracts';
2
+ export declare function generatedImage(data: string, prompt?: string): {
3
+ upload: ChatAttachmentUpload;
4
+ width?: number;
5
+ height?: number;
6
+ };
@@ -0,0 +1,74 @@
1
+ import { ChatAttachmentUploadSchema } from '@adecore/agent-contracts';
2
+ function imageInfo(bytes) {
3
+ if (bytes.length >= 33 &&
4
+ bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
5
+ bytes.readUInt32BE(8) === 13 &&
6
+ bytes.toString('ascii', 12, 16) === 'IHDR') {
7
+ return { mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
8
+ }
9
+ if (bytes.length >= 13 && /GIF8[79]a/.test(bytes.toString('ascii', 0, 6))) {
10
+ return { mime: 'image/gif', extension: 'gif', width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
11
+ }
12
+ if (bytes.length >= 20 &&
13
+ bytes.toString('ascii', 0, 4) === 'RIFF' &&
14
+ bytes.toString('ascii', 8, 12) === 'WEBP' &&
15
+ bytes.readUInt32LE(4) + 8 === bytes.length) {
16
+ const kind = bytes.toString('ascii', 12, 16);
17
+ if (kind === 'VP8X' && bytes.length >= 30) {
18
+ return { mime: 'image/webp', extension: 'webp', width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
19
+ }
20
+ if (kind === 'VP8 ' && bytes.length >= 30 && bytes.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
21
+ return { mime: 'image/webp', extension: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
22
+ }
23
+ if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
24
+ const size = bytes.readUInt32LE(21);
25
+ return { mime: 'image/webp', extension: 'webp', width: (size & 0x3fff) + 1, height: ((size >> 14) & 0x3fff) + 1 };
26
+ }
27
+ return null;
28
+ }
29
+ if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
30
+ let offset = 2;
31
+ while (offset + 4 <= bytes.length && bytes[offset] === 255) {
32
+ const marker = bytes[offset + 1];
33
+ if (marker === 218 || marker === 217) {
34
+ break;
35
+ }
36
+ if (marker === 255) {
37
+ offset++;
38
+ continue;
39
+ }
40
+ const size = bytes.readUInt16BE(offset + 2);
41
+ if (size < 2 || offset + 2 + size > bytes.length) {
42
+ return null;
43
+ }
44
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && size >= 8) {
45
+ return { mime: 'image/jpeg', extension: 'jpg', width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
46
+ }
47
+ offset += size + 2;
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+ export function generatedImage(data, prompt) {
53
+ const parsed = ChatAttachmentUploadSchema.safeParse({ name: 'image', mime: 'application/octet-stream', data });
54
+ if (!parsed.success) {
55
+ throw new Error('The generated image is empty, invalid base64 or exceeds 10 MiB');
56
+ }
57
+ const bytes = Buffer.from(data, 'base64');
58
+ // Buffer's decoder accepts malformed padding and trailing bits; the protocol requires canonical base64.
59
+ if (bytes.toString('base64') !== data) {
60
+ throw new Error('The generated image contains invalid base64');
61
+ }
62
+ const info = imageInfo(bytes);
63
+ if (info === null || (info.width !== undefined && info.width === 0) || (info.height !== undefined && info.height === 0)) {
64
+ throw new Error('The generated bytes are not a supported image');
65
+ }
66
+ const stem = (prompt ?? '')
67
+ .normalize('NFKD')
68
+ .replace(/[^a-zA-Z0-9]+/g, '-')
69
+ .replace(/^-|-$/g, '')
70
+ .slice(0, 80)
71
+ .toLowerCase() || 'generated-image';
72
+ const upload = { name: `${stem}.${info.extension}`, mime: info.mime, data };
73
+ return { upload, ...(info.width === undefined ? {} : { width: info.width }), ...(info.height === undefined ? {} : { height: info.height }) };
74
+ }
@@ -150,7 +150,7 @@ export class ThreadProjector {
150
150
  break;
151
151
  case 'text.delta':
152
152
  this.closeThinking(events);
153
- this.appendText(this.itemId(generation, event.ref), event.text, events);
153
+ this.appendText(this.itemId(generation, event.ref), event.text, events, event.phase);
154
154
  break;
155
155
  case 'text.done': {
156
156
  if (event.parentRef) {
@@ -168,6 +168,12 @@ export class ThreadProjector {
168
168
  createdAt: existing?.createdAt ?? this.now(),
169
169
  turnId: existing?.turnId ?? info.activeTurnId,
170
170
  text: event.text,
171
+ ...(existing?.kind === 'assistant' ? { uiAnswers: existing.uiAnswers, uiQueries: existing.uiQueries } : {}),
172
+ ...(event.phase === undefined
173
+ ? existing?.kind === 'assistant' && existing.phase !== undefined
174
+ ? { phase: existing.phase }
175
+ : {}
176
+ : { phase: event.phase }),
171
177
  streaming: false
172
178
  }));
173
179
  break;
@@ -452,7 +458,7 @@ export class ThreadProjector {
452
458
  events.push(this.thread.upsert({ ...item, streaming: false, endedAt: this.now() }));
453
459
  }
454
460
  }
455
- appendText(id, text, events) {
461
+ appendText(id, text, events, phase) {
456
462
  const existing = this.thread.get(id);
457
463
  if (!existing) {
458
464
  events.push(this.thread.upsert({
@@ -461,6 +467,7 @@ export class ThreadProjector {
461
467
  createdAt: this.now(),
462
468
  turnId: this.thread.info.activeTurnId,
463
469
  text,
470
+ ...(phase === undefined ? {} : { phase }),
464
471
  streaming: true
465
472
  }));
466
473
  return;
@@ -82,6 +82,12 @@ export class ChatThread {
82
82
  break;
83
83
  case 'delta':
84
84
  this.appendText(event.itemId, event.text);
85
+ if (event.uiQueries) {
86
+ const item = this.items.get(event.itemId);
87
+ if (item?.kind === 'assistant') {
88
+ this.items.set(item.id, { ...item, uiQueries: event.uiQueries });
89
+ }
90
+ }
85
91
  break;
86
92
  case 'info':
87
93
  this.info = event.info;