@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,6 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import type {
3
3
  ChatAttachment,
4
+ ChatAssistantItem,
4
5
  ChatCheckpointDiff,
5
6
  ChatEvent,
6
7
  ChatInfo,
@@ -9,10 +10,17 @@ import type {
9
10
  ChatSkill,
10
11
  ChatSubagentItem,
11
12
  ChatTurnItem,
13
+ ChatUiAnswer,
14
+ ChatUiChoiceOrigin,
15
+ ChatUiChoicePayload,
16
+ ChatUiQueryState,
12
17
  ModelSelection,
13
18
  RuntimeMode
14
19
  } from '@adecore/agent-contracts';
15
- import { notResumedNote } from '@adecore/agent-contracts';
20
+ import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
21
+ import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
22
+ import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
23
+ import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
16
24
  import type { ChatProvider } from '../providers/provider.ts';
17
25
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
18
26
  import type { BackendEvent, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
@@ -58,6 +66,7 @@ export interface ChatSessionOptions {
58
66
  /* The environment of the CLI under the chat's account, asked at every start; throws for an account that cannot start. */
59
67
  env(account: string | undefined): Record<string, string>;
60
68
  spawn?: SpawnChatProcess;
69
+ saveGeneratedImage?: BackendLaunch['saveGeneratedImage'];
61
70
  // What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
62
71
  instructions?(): string | null;
63
72
  resumeNote?(): string | null;
@@ -67,6 +76,10 @@ export interface ChatSessionOptions {
67
76
  references?: ChatReferences;
68
77
  // Trees per turn, so a settled turn can show what the working tree holds against its start.
69
78
  checkpoints?: TurnCheckpoints;
79
+ ui?: {
80
+ schemas: UiCompileOptions['querySchemas'];
81
+ observe(item: ChatAssistantItem, final: boolean): void;
82
+ };
70
83
  emit(event: ChatEvent): void;
71
84
  /* What a turn said about the plan it runs on. It belongs to the machine, so it leaves the chat. */
72
85
  onLimits?(update: LimitsUpdate): void;
@@ -98,6 +111,7 @@ export interface LimitResumeHooks {
98
111
  const TITLE_RECHECK_MS = 10_000;
99
112
 
100
113
  export interface ChatSendExtras {
114
+ uiChoice?: ChatUiChoiceOrigin;
101
115
  delivery?: 'steer' | 'queue';
102
116
  mentions?: string[];
103
117
  skills?: string[];
@@ -177,6 +191,8 @@ export class ChatSession {
177
191
  private pendingPreambles: readonly string[];
178
192
  private submission: { turnId: string; input: TurnInput } | null = null;
179
193
  private readonly steering = new Set<string>();
194
+ private readonly uiStreams = new Map<string, UiStream>();
195
+ private readonly uiCompiled = new Map<string, UiStreamPreview>();
180
196
 
181
197
  constructor(options: ChatSessionOptions) {
182
198
  this.options = options;
@@ -185,6 +201,24 @@ export class ChatSession {
185
201
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
186
202
  }
187
203
 
204
+ get uiPreviews(): ChatEvent[] {
205
+ const events: ChatEvent[] = [];
206
+ for (const [itemId, preview] of this.uiCompiled) {
207
+ const item = this.thread.get(itemId);
208
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
209
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
210
+ }
211
+ }
212
+ return events;
213
+ }
214
+
215
+ previewItems(items: ChatItem[]): ChatItem[] {
216
+ return items.map((item) => {
217
+ const preview = this.uiCompiled.get(item.id);
218
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
219
+ });
220
+ }
221
+
188
222
  get id(): string {
189
223
  return this.thread.info.chatId;
190
224
  }
@@ -265,6 +299,7 @@ export class ChatSession {
265
299
  turnId,
266
300
  text,
267
301
  createdAt: Date.now(),
302
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
268
303
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
269
304
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
270
305
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
@@ -279,6 +314,83 @@ export class ChatSession {
279
314
  return { queued: false, turnId };
280
315
  }
281
316
 
317
+ updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
318
+ const item = this.thread.get(itemId);
319
+ if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
320
+ return;
321
+ }
322
+ this.thread.upsert({ ...item, uiQueries });
323
+ this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
324
+ this.options.persistSoon();
325
+ }
326
+
327
+ choose(payload: ChatUiChoicePayload, queries: Readonly<Record<string, unknown>> = {}): { queued: boolean; turnId: string } {
328
+ const item = this.thread.get(payload.itemId);
329
+ const block =
330
+ item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
331
+ if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
332
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
333
+ }
334
+ const sameBlock = (origin: ChatUiChoiceOrigin | undefined) =>
335
+ origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
336
+ let previous = item.uiAnswers?.[block.id];
337
+ if (previous?.revision !== block.revision) {
338
+ previous = undefined;
339
+ }
340
+ // The message is logged first: a crash before its answer marker must not send it a second time.
341
+ if (!previous) {
342
+ const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
343
+ const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
344
+ const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
345
+ const turnId = sent?.turnId ?? queued?.turnId;
346
+ if (origin && turnId) {
347
+ previous = { ...origin, turnId, queued: !sent };
348
+ this.recordUiAnswer(previous);
349
+ }
350
+ }
351
+ if (previous) {
352
+ if (previous.choiceId !== payload.choiceId) {
353
+ throw new ChatError('ui-already-answered', 'This UI block was already answered');
354
+ }
355
+ return { queued: previous.queued, turnId: previous.turnId };
356
+ }
357
+ let selected;
358
+ try {
359
+ selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
360
+ } catch (error) {
361
+ throw new ChatError(
362
+ error instanceof UiFailure ? error.code : 'refused-choice',
363
+ error instanceof Error ? error.message : 'This choice could not be read'
364
+ );
365
+ }
366
+ const origin: ChatUiChoiceOrigin = {
367
+ itemId: item.id,
368
+ blockId: block.id,
369
+ revision: block.revision,
370
+ choiceId: payload.choiceId,
371
+ label: selected.label,
372
+ sourceAt: item.createdAt,
373
+ older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
374
+ values: selected.values,
375
+ at: Date.now()
376
+ };
377
+ const result = this.send(selected.context, { uiChoice: origin });
378
+ this.recordUiAnswer({ ...origin, ...result });
379
+ return result;
380
+ }
381
+
382
+ private recordUiAnswer(answer: ChatUiAnswer): void {
383
+ const item = this.thread.get(answer.itemId);
384
+ if (item?.kind !== 'assistant') {
385
+ return;
386
+ }
387
+ if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
388
+ return;
389
+ }
390
+ this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
391
+ this.options.persist();
392
+ }
393
+
282
394
  sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
283
395
  return this.submitInput(text, extras);
284
396
  }
@@ -415,7 +527,11 @@ export class ChatSession {
415
527
  this.sendAfterReplacement = false;
416
528
  this.pauseQueue(false);
417
529
  this.setQueue(rest);
418
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
530
+ this.dispatch(
531
+ next.text,
532
+ { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice },
533
+ next.turnId
534
+ );
419
535
  }
420
536
 
421
537
  private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
@@ -425,7 +541,10 @@ export class ChatSession {
425
541
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
426
542
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
427
543
  const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
428
- const said = [preamble, references.note].filter((part): part is string => part !== null);
544
+ const choiceNote = extras.uiChoice
545
+ ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
546
+ : null;
547
+ const said = [preamble, references.note, choiceNote].filter((part): part is string => part !== null);
429
548
  const input = {
430
549
  text,
431
550
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -544,14 +663,14 @@ export class ChatSession {
544
663
 
545
664
  /*
546
665
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
547
- * A turn in the way is refused unless forced, and a forced clear does not wait for it to end,
548
- * since the turn disappears with the thread anyway. Writing the empty thread is the caller's.
666
+ * The thread resets at once; the returned promise lets the host wait for pending attachment
667
+ * writes before removing their files. Writing the empty thread is the caller's.
549
668
  */
550
- clear(force: boolean): void {
669
+ clear(force: boolean): Promise<void> {
551
670
  if (this.busy && !force) {
552
671
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
553
672
  }
554
- void this.dispose();
673
+ const ended = this.dispose();
555
674
  this.generation = nextGeneration();
556
675
  this.projector.reset();
557
676
  // The fresh CLI hears what the host tells it at launch, as it would on a first turn.
@@ -590,6 +709,7 @@ export class ChatSession {
590
709
  }
591
710
  })
592
711
  ]);
712
+ return ended;
593
713
  }
594
714
 
595
715
  /*
@@ -994,6 +1114,7 @@ export class ChatSession {
994
1114
 
995
1115
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
996
1116
  freeze(): void {
1117
+ this.clearUi();
997
1118
  this.clearStartWarning();
998
1119
  this.clearWorkflowWarnings();
999
1120
  this.frozen = true;
@@ -1002,6 +1123,7 @@ export class ChatSession {
1002
1123
 
1003
1124
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
1004
1125
  dispose(): Promise<void> {
1126
+ this.clearUi();
1005
1127
  this.clearStartWarning();
1006
1128
  this.clearWorkflowWarnings();
1007
1129
  if (this.titleTimer !== null) {
@@ -1159,10 +1281,26 @@ export class ChatSession {
1159
1281
  const skills = extras.skills?.length ? extras.skills : undefined;
1160
1282
  const chats = extras.chats?.length ? extras.chats : undefined;
1161
1283
  const attachments = extras.attachments?.length ? extras.attachments : undefined;
1162
- events.push(this.thread.upsert({ id: newId('user'), kind: 'user', createdAt: now, turnId, text, mentions, skills, chats, attachments }));
1284
+ events.push(
1285
+ this.thread.upsert({
1286
+ id: newId('user'),
1287
+ kind: 'user',
1288
+ createdAt: now,
1289
+ turnId,
1290
+ text,
1291
+ mentions,
1292
+ skills,
1293
+ chats,
1294
+ attachments,
1295
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1296
+ })
1297
+ );
1163
1298
  }
1164
1299
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
1165
1300
  this.emit(events);
1301
+ if (extras.uiChoice) {
1302
+ this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1303
+ }
1166
1304
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
1167
1305
  this.options.persist();
1168
1306
  return turnId;
@@ -1392,6 +1530,7 @@ export class ChatSession {
1392
1530
  runtimeMode: info.runtimeMode,
1393
1531
  resume: info.agentSessionId,
1394
1532
  generation,
1533
+ saveGeneratedImage: this.options.saveGeneratedImage,
1395
1534
  instructions: this.options.instructions?.() ?? null,
1396
1535
  resumeNote: this.options.resumeNote?.() ?? null,
1397
1536
  ...(folders.length > 0 ? { folders } : {}),
@@ -1743,9 +1882,82 @@ export class ChatSession {
1743
1882
 
1744
1883
  private emit(events: ChatEvent[]): void {
1745
1884
  const delegating = this.delegatingChange(events);
1746
- for (const event of delegating === null ? events : [...events, delegating]) {
1747
- this.options.emit(event);
1885
+ for (let event of delegating === null ? events : [...events, delegating]) {
1886
+ if (event.type === 'reset') {
1887
+ this.clearUi();
1888
+ }
1889
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1890
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
1891
+ this.options.emit(event);
1892
+ continue;
1893
+ }
1894
+ let stream = this.uiStreams.get(item.id);
1895
+ if (!stream && !item.text.includes('ruimte-ui')) {
1896
+ this.options.emit(event);
1897
+ continue;
1898
+ }
1899
+ if (!stream) {
1900
+ stream = new UiStream({
1901
+ querySchemas: this.options.ui?.schemas,
1902
+ id: item.id,
1903
+ clock: this.options.clock ?? systemClock,
1904
+ emit: (preview) => {
1905
+ this.uiCompiled.set(item.id, preview);
1906
+ this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1907
+ }
1908
+ });
1909
+ this.uiStreams.set(item.id, stream);
1910
+ }
1911
+ this.options.ui?.observe(item, false);
1912
+ const latestAttachment = this.latestUiAttachment(item.turnId);
1913
+ if (event.type === 'item' && !item.streaming) {
1914
+ const ui = stream
1915
+ .finish(item.text, latestAttachment)
1916
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1917
+ this.uiStreams.delete(item.id);
1918
+ this.uiCompiled.delete(item.id);
1919
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1920
+ if (diagnostics.length > 0) {
1921
+ const repair =
1922
+ `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1923
+ 0,
1924
+ 1200
1925
+ );
1926
+ if (!this.pendingPreambles.includes(repair)) {
1927
+ this.pendingPreambles = [...this.pendingPreambles, repair];
1928
+ }
1929
+ }
1930
+ const current = this.thread.get(item.id);
1931
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1932
+ event = this.thread.upsert(completed);
1933
+ this.options.emit(event);
1934
+ this.options.ui?.observe(completed, true);
1935
+ } else {
1936
+ this.options.emit(event);
1937
+ stream.update(item.text, latestAttachment);
1938
+ }
1939
+ }
1940
+ }
1941
+
1942
+ private latestUiAttachment(turnId: string | null): string | undefined {
1943
+ for (const item of this.thread.list().toReversed()) {
1944
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1945
+ continue;
1946
+ }
1947
+ const input = GeneratedImageInputSchema.safeParse(item.input);
1948
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
1949
+ return input.data.attachment.id;
1950
+ }
1951
+ }
1952
+ return undefined;
1953
+ }
1954
+
1955
+ private clearUi(): void {
1956
+ for (const stream of this.uiStreams.values()) {
1957
+ stream.dispose();
1748
1958
  }
1959
+ this.uiStreams.clear();
1960
+ this.uiCompiled.clear();
1749
1961
  }
1750
1962
 
1751
1963
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
@@ -48,6 +48,7 @@ export class CodexBackend implements ChatBackend {
48
48
  readonly acknowledgesTurns = true;
49
49
  private promptId: string | null = null;
50
50
  private readonly launch: BackendLaunch;
51
+ private imageWrites: Promise<void> | null = null;
51
52
  private readonly host: BackendHost;
52
53
  private readonly protocol: CodexProtocol;
53
54
  private readonly client: CodexClientInfo;
@@ -351,7 +352,7 @@ export class CodexBackend implements ChatBackend {
351
352
  dispose(): Promise<void> {
352
353
  const transport = this.transport;
353
354
  this.transport = null;
354
- return transport?.dispose() ?? Promise.resolve();
355
+ return Promise.all([transport?.dispose(), this.imageWrites]).then(() => undefined);
355
356
  }
356
357
 
357
358
  /* A request whose failure the person has to hear about, since it is the turn that cannot go on. */
@@ -415,6 +416,54 @@ export class CodexBackend implements ChatBackend {
415
416
  this.interruptOwed = false;
416
417
  this.promptId = null;
417
418
  }
419
+ if (event.type === 'image.generated' || this.imageWrites !== null) {
420
+ const pending = (this.imageWrites ?? Promise.resolve()).then(async () => {
421
+ if (event.type === 'image.generated') {
422
+ await this.storeImage(event);
423
+ } else {
424
+ this.host.onEvent(event);
425
+ }
426
+ });
427
+ this.imageWrites = pending;
428
+ void pending
429
+ .finally(() => {
430
+ if (this.imageWrites === pending) {
431
+ this.imageWrites = null;
432
+ }
433
+ })
434
+ .catch(() => undefined);
435
+ return;
436
+ }
418
437
  this.host.onEvent(event);
419
438
  }
439
+
440
+ private async storeImage(event: Extract<BackendEvent, { type: 'image.generated' }>): Promise<void> {
441
+ const input = { revisedPrompt: event.revisedPrompt, transparentBackground: event.transparentBackground };
442
+ let attachment;
443
+ try {
444
+ if (!this.launch.saveGeneratedImage) {
445
+ throw new Error('This machine cannot save generated images');
446
+ }
447
+ attachment = await this.launch.saveGeneratedImage(event.ref, event.upload);
448
+ } catch (error) {
449
+ this.host.onEvent({ type: 'tool.started', ref: event.ref, name: 'ImageGeneration', input, parentRef: null });
450
+ this.host.onEvent({ type: 'tool.done', ref: event.ref, output: errorText(error), state: 'error' });
451
+ return;
452
+ }
453
+ this.host.onEvent({
454
+ type: 'tool.started',
455
+ ref: event.ref,
456
+ name: 'ImageGeneration',
457
+ input: {
458
+ ...input,
459
+ attachment: {
460
+ ...attachment,
461
+ ...(event.width === undefined ? {} : { width: event.width }),
462
+ ...(event.height === undefined ? {} : { height: event.height })
463
+ }
464
+ },
465
+ parentRef: null
466
+ });
467
+ this.host.onEvent({ type: 'tool.done', ref: event.ref, output: null, state: 'done' });
468
+ }
420
469
  }
@@ -1,3 +1,4 @@
1
+ import { generatedImage } from './generated-image.ts';
1
2
  import type { ChatFileChange, ChatQuestion, ChatTurnLimit, UsageWindow } from '@adecore/agent-contracts';
2
3
  import { readCodexLimits } from '../usage/limits/normalize.ts';
3
4
  import { codexRuntimeMode } from '../providers/codex.ts';
@@ -632,7 +633,7 @@ export class CodexProtocol {
632
633
  events.push({ type: 'question.requested', requestId: ref, questions, async: true });
633
634
  return;
634
635
  }
635
- this.text(ref, str(item.text) ?? '', completed, events);
636
+ this.text(ref, str(item.text) ?? '', completed, events, str(item.phase) ?? undefined);
636
637
  return;
637
638
  }
638
639
  case 'plan':
@@ -705,6 +706,33 @@ export class CodexProtocol {
705
706
  case 'dynamicToolCall':
706
707
  this.tool(ref, str(item.tool) ?? 'tool', item.arguments ?? {}, completed, textOf(item.contentItems), item.success !== false, events);
707
708
  return;
709
+ case 'imageGeneration': {
710
+ const revisedPrompt = str(item.revisedPrompt) ?? undefined;
711
+ const transparentBackground = typeof item.transparentBackground === 'boolean' ? item.transparentBackground : undefined;
712
+ const input = {
713
+ ...(revisedPrompt === undefined ? {} : { revisedPrompt }),
714
+ ...(transparentBackground === undefined ? {} : { transparentBackground })
715
+ };
716
+ if (!completed) {
717
+ this.tool(ref, 'ImageGeneration', input, false, '', true, events);
718
+ return;
719
+ }
720
+ try {
721
+ if (item.failure || item.status !== 'completed') {
722
+ throw new Error(
723
+ isRecord(item.failure) ? (str(item.failure.message) ?? 'Image generation failed') : (str(item.failure) ?? 'Image generation failed')
724
+ );
725
+ }
726
+ const image = generatedImage(str(item.result) ?? '', revisedPrompt);
727
+ events.push({ type: 'image.generated', ref, ...image, ...input });
728
+ } catch (error) {
729
+ this.tool(ref, 'ImageGeneration', input, true, error instanceof Error ? error.message : 'Image generation failed', false, events);
730
+ }
731
+ return;
732
+ }
733
+ case 'imageView':
734
+ this.tool(ref, 'ImageView', { path: str(item.path) ?? '' }, completed, '', true, events);
735
+ return;
708
736
  case 'webSearch': {
709
737
  const action = isRecord(item.action) ? item.action : {};
710
738
  this.tool(ref, 'WebSearch', { query: str(item.query) ?? str(action.query) ?? '' }, completed, '', true, events);
@@ -775,8 +803,8 @@ export class CodexProtocol {
775
803
  return null;
776
804
  }
777
805
 
778
- private text(ref: string, text: string, completed: boolean, events: BackendEvent[]): void {
779
- events.push(completed ? { type: 'text.done', ref, text } : { type: 'text.delta', ref, text });
806
+ private text(ref: string, text: string, completed: boolean, events: BackendEvent[], phase?: string): void {
807
+ events.push({ type: completed ? 'text.done' : 'text.delta', ref, text, ...(phase === undefined ? {} : { phase }) });
780
808
  }
781
809
 
782
810
  /*
@@ -21,7 +21,7 @@ export class DeltaCoalescer {
21
21
  }
22
22
 
23
23
  push(event: ChatEvent): void {
24
- if (event.type !== 'delta') {
24
+ if (event.type !== 'delta' || event.uiQueries !== undefined) {
25
25
  this.flush();
26
26
  this.send(event);
27
27
  return;
@@ -1,6 +1,8 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
2
 
3
3
  export type ChatErrorCode =
4
+ | 'refused-query'
5
+ | 'stale-ui-query'
4
6
  | 'history-expired'
5
7
  | 'chat-not-found'
6
8
  // A record on disk this version cannot read, which is never written over.
@@ -197,6 +197,40 @@ export function fakeCodexWith(options: FakeCodexOptions): FakeCli {
197
197
  turnCompleted('failed', "You've hit your usage limit.", 'usageLimitExceeded');
198
198
  return;
199
199
  }
200
+ if (text.startsWith('image:')) {
201
+ const scenario = text.slice(6);
202
+ const entry = item('imageGeneration', {
203
+ status: 'inProgress',
204
+ result: '',
205
+ savedPath: '/never-read-generated-image.png',
206
+ revisedPrompt: 'A rabbit',
207
+ transparentBackground: true
208
+ });
209
+ started(entry);
210
+ const result =
211
+ scenario === 'empty'
212
+ ? ''
213
+ : scenario === 'invalid'
214
+ ? 'not base64'
215
+ : scenario === 'not-image'
216
+ ? Buffer.from('text').toString('base64')
217
+ : scenario === 'too-large'
218
+ ? Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64')
219
+ : 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
220
+ const done = {
221
+ ...entry,
222
+ status: scenario === 'failed' ? 'failed' : 'completed',
223
+ result,
224
+ ...(scenario === 'failed' ? { failure: { message: 'Image generation failed' } } : {})
225
+ };
226
+ completed(done);
227
+ if (scenario === 'replay') {
228
+ completed(done);
229
+ }
230
+ agentMessage('Here is the generated image.');
231
+ turnCompleted('completed');
232
+ return;
233
+ }
200
234
  if (text === 'slow') {
201
235
  slow = true;
202
236
  started(item('agentMessage', { text: '', phase: 'final_answer', memoryCitation: null, delivery: null, questions: null }));
@@ -0,0 +1,88 @@
1
+ import { ChatAttachmentUploadSchema, type ChatAttachmentUpload } from '@adecore/agent-contracts';
2
+
3
+ interface ImageInfo {
4
+ mime: string;
5
+ extension: string;
6
+ width?: number;
7
+ height?: number;
8
+ }
9
+
10
+ function imageInfo(bytes: Buffer): ImageInfo | null {
11
+ if (
12
+ bytes.length >= 33 &&
13
+ bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
14
+ bytes.readUInt32BE(8) === 13 &&
15
+ bytes.toString('ascii', 12, 16) === 'IHDR'
16
+ ) {
17
+ return { mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
18
+ }
19
+ if (bytes.length >= 13 && /GIF8[79]a/.test(bytes.toString('ascii', 0, 6))) {
20
+ return { mime: 'image/gif', extension: 'gif', width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
21
+ }
22
+ if (
23
+ bytes.length >= 20 &&
24
+ bytes.toString('ascii', 0, 4) === 'RIFF' &&
25
+ bytes.toString('ascii', 8, 12) === 'WEBP' &&
26
+ bytes.readUInt32LE(4) + 8 === bytes.length
27
+ ) {
28
+ const kind = bytes.toString('ascii', 12, 16);
29
+ if (kind === 'VP8X' && bytes.length >= 30) {
30
+ return { mime: 'image/webp', extension: 'webp', width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
31
+ }
32
+ if (kind === 'VP8 ' && bytes.length >= 30 && bytes.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
33
+ return { mime: 'image/webp', extension: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
34
+ }
35
+ if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
36
+ const size = bytes.readUInt32LE(21);
37
+ return { mime: 'image/webp', extension: 'webp', width: (size & 0x3fff) + 1, height: ((size >> 14) & 0x3fff) + 1 };
38
+ }
39
+ return null;
40
+ }
41
+ if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
42
+ let offset = 2;
43
+ while (offset + 4 <= bytes.length && bytes[offset] === 255) {
44
+ const marker = bytes[offset + 1]!;
45
+ if (marker === 218 || marker === 217) {
46
+ break;
47
+ }
48
+ if (marker === 255) {
49
+ offset++;
50
+ continue;
51
+ }
52
+ const size = bytes.readUInt16BE(offset + 2);
53
+ if (size < 2 || offset + 2 + size > bytes.length) {
54
+ return null;
55
+ }
56
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && size >= 8) {
57
+ return { mime: 'image/jpeg', extension: 'jpg', width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
58
+ }
59
+ offset += size + 2;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function generatedImage(data: string, prompt?: string): { upload: ChatAttachmentUpload; width?: number; height?: number } {
66
+ const parsed = ChatAttachmentUploadSchema.safeParse({ name: 'image', mime: 'application/octet-stream', data });
67
+ if (!parsed.success) {
68
+ throw new Error('The generated image is empty, invalid base64 or exceeds 10 MiB');
69
+ }
70
+ const bytes = Buffer.from(data, 'base64');
71
+ // Buffer's decoder accepts malformed padding and trailing bits; the protocol requires canonical base64.
72
+ if (bytes.toString('base64') !== data) {
73
+ throw new Error('The generated image contains invalid base64');
74
+ }
75
+ const info = imageInfo(bytes);
76
+ if (info === null || (info.width !== undefined && info.width === 0) || (info.height !== undefined && info.height === 0)) {
77
+ throw new Error('The generated bytes are not a supported image');
78
+ }
79
+ const stem =
80
+ (prompt ?? '')
81
+ .normalize('NFKD')
82
+ .replace(/[^a-zA-Z0-9]+/g, '-')
83
+ .replace(/^-|-$/g, '')
84
+ .slice(0, 80)
85
+ .toLowerCase() || 'generated-image';
86
+ const upload = { name: `${stem}.${info.extension}`, mime: info.mime, data };
87
+ return { upload, ...(info.width === undefined ? {} : { width: info.width }), ...(info.height === undefined ? {} : { height: info.height }) };
88
+ }