@adecore/agents 0.20.1 → 0.21.1

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 (43) hide show
  1. package/dist/chat/attachment-store.d.ts +2 -0
  2. package/dist/chat/attachment-store.js +38 -2
  3. package/dist/chat/backend.d.ts +12 -1
  4. package/dist/chat/chat-core.d.ts +57 -1
  5. package/dist/chat/chat-core.js +67 -14
  6. package/dist/chat/chat-session.d.ts +35 -2
  7. package/dist/chat/chat-session.js +261 -11
  8. package/dist/chat/chat-store.d.ts +3 -2
  9. package/dist/chat/chat-store.js +42 -11
  10. package/dist/chat/codex-backend.d.ts +2 -0
  11. package/dist/chat/codex-backend.js +51 -1
  12. package/dist/chat/codex-protocol.js +30 -3
  13. package/dist/chat/delta-coalescer.js +1 -1
  14. package/dist/chat/errors.d.ts +1 -1
  15. package/dist/chat/fake-codex.js +33 -0
  16. package/dist/chat/generated-image.d.ts +6 -0
  17. package/dist/chat/generated-image.js +74 -0
  18. package/dist/chat/projector.js +9 -2
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/thread.js +6 -0
  22. package/dist/chat/ui-queries.d.ts +49 -0
  23. package/dist/chat/ui-queries.js +358 -0
  24. package/dist/host/handlers.js +3 -0
  25. package/dist/tasks/task-coordinator.js +6 -2
  26. package/package.json +5 -4
  27. package/src/chat/attachment-store.ts +38 -2
  28. package/src/chat/backend.ts +14 -2
  29. package/src/chat/chat-core.ts +84 -13
  30. package/src/chat/chat-session.ts +304 -12
  31. package/src/chat/chat-store.ts +53 -11
  32. package/src/chat/codex-backend.ts +50 -1
  33. package/src/chat/codex-protocol.ts +31 -3
  34. package/src/chat/delta-coalescer.ts +1 -1
  35. package/src/chat/errors.ts +2 -0
  36. package/src/chat/fake-codex.ts +34 -0
  37. package/src/chat/generated-image.ts +88 -0
  38. package/src/chat/projector.ts +9 -2
  39. package/src/chat/readable-text.ts +18 -0
  40. package/src/chat/thread.ts +6 -0
  41. package/src/chat/ui-queries.ts +416 -0
  42. package/src/host/handlers.ts +3 -0
  43. package/src/tasks/task-coordinator.ts +6 -2
@@ -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, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } 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';
@@ -22,6 +30,7 @@ import type { ChatTitleInput } from './chat-title.ts';
22
30
  import { ChatError } from './errors.ts';
23
31
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.ts';
24
32
  import { isMainAgentOutput, ThreadProjector } from './projector.ts';
33
+ import { readableAssistantText } from './readable-text.ts';
25
34
  import type { SubagentSettlement } from './subagent-settlement.ts';
26
35
  import { ChatThread } from './thread.ts';
27
36
  import { errorText } from '../error-text.ts';
@@ -52,12 +61,15 @@ export interface ChatSessionOptions {
52
61
  items?: ChatItem[];
53
62
  // Said once, in front of the next real prompt, and kept in the record until then (a fork's note for its agent).
54
63
  preambles?: string[];
64
+ // The access a UI host captured per reply, from the record; never on the wire.
65
+ uiAccess?: Readonly<Record<string, unknown>>;
55
66
  provider: ChatProvider;
56
67
  // The executable and leading arguments; a test points this at a fake CLI.
57
68
  command: string[];
58
69
  /* The environment of the CLI under the chat's account, asked at every start; throws for an account that cannot start. */
59
70
  env(account: string | undefined): Record<string, string>;
60
71
  spawn?: SpawnChatProcess;
72
+ saveGeneratedImage?: BackendLaunch['saveGeneratedImage'];
61
73
  // What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
62
74
  instructions?(): string | null;
63
75
  resumeNote?(): string | null;
@@ -67,6 +79,12 @@ export interface ChatSessionOptions {
67
79
  references?: ChatReferences;
68
80
  // Trees per turn, so a settled turn can show what the working tree holds against its start.
69
81
  checkpoints?: TurnCheckpoints;
82
+ // The info string of the host's UI fences; `UI_FENCE_LANGUAGE` without one.
83
+ uiFenceLanguage?: string;
84
+ ui?: {
85
+ schemas: UiCompileOptions['querySchemas'];
86
+ observe(item: ChatAssistantItem, final: boolean): void;
87
+ };
70
88
  emit(event: ChatEvent): void;
71
89
  /* What a turn said about the plan it runs on. It belongs to the machine, so it leaves the chat. */
72
90
  onLimits?(update: LimitsUpdate): void;
@@ -96,8 +114,11 @@ export interface LimitResumeHooks {
96
114
 
97
115
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
98
116
  const TITLE_RECHECK_MS = 10_000;
117
+ // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
118
+ const UI_SCAN_OVERLAP = 64;
99
119
 
100
120
  export interface ChatSendExtras {
121
+ uiChoice?: ChatUiChoiceOrigin;
101
122
  delivery?: 'steer' | 'queue';
102
123
  mentions?: string[];
103
124
  skills?: string[];
@@ -177,14 +198,42 @@ export class ChatSession {
177
198
  private pendingPreambles: readonly string[];
178
199
  private submission: { turnId: string; input: TurnInput } | null = null;
179
200
  private readonly steering = new Set<string>();
201
+ private readonly uiStreams = new Map<string, UiStream>();
202
+ private readonly uiCompiled = new Map<string, UiStreamPreview>();
203
+ private readonly capturedUiAccess: Map<string, unknown>;
204
+ // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
205
+ private readonly uiScanned = new Map<string, number>();
206
+ private readonly uiReferencing = new Set<string>();
207
+ private readonly uiAttachments = new Map<string | null, string | undefined>();
208
+ private readonly uiFenceLanguage: string;
180
209
 
181
210
  constructor(options: ChatSessionOptions) {
182
211
  this.options = options;
212
+ this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
183
213
  this.pendingPreambles = options.preambles ?? [];
214
+ this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
184
215
  this.thread = new ChatThread(options.info, options.items);
185
216
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
186
217
  }
187
218
 
219
+ get uiPreviews(): ChatEvent[] {
220
+ const events: ChatEvent[] = [];
221
+ for (const [itemId, preview] of this.uiCompiled) {
222
+ const item = this.thread.get(itemId);
223
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
224
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
225
+ }
226
+ }
227
+ return events;
228
+ }
229
+
230
+ previewItems(items: ChatItem[]): ChatItem[] {
231
+ return items.map((item) => {
232
+ const preview = this.uiCompiled.get(item.id);
233
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
234
+ });
235
+ }
236
+
188
237
  get id(): string {
189
238
  return this.thread.info.chatId;
190
239
  }
@@ -265,6 +314,7 @@ export class ChatSession {
265
314
  turnId,
266
315
  text,
267
316
  createdAt: Date.now(),
317
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
268
318
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
269
319
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
270
320
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
@@ -279,6 +329,101 @@ export class ChatSession {
279
329
  return { queued: false, turnId };
280
330
  }
281
331
 
332
+ /* What a UI host captured for a reply; null where the capture was denied or is still out, undefined where none started. */
333
+ uiAccess(itemId: string): unknown {
334
+ return this.capturedUiAccess.get(itemId);
335
+ }
336
+
337
+ setUiAccess(itemId: string, access: unknown): void {
338
+ if (this.frozen) {
339
+ return;
340
+ }
341
+ this.capturedUiAccess.set(itemId, access);
342
+ this.options.persistSoon();
343
+ }
344
+
345
+ /* The captured access of the replies still in the thread, for the record. */
346
+ get uiAccessRecord(): Record<string, unknown> {
347
+ return Object.fromEntries([...this.capturedUiAccess].filter(([itemId]) => this.thread.get(itemId) !== undefined));
348
+ }
349
+
350
+ updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
351
+ const item = this.thread.get(itemId);
352
+ if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
353
+ return;
354
+ }
355
+ this.thread.upsert({ ...item, uiQueries });
356
+ this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
357
+ this.options.persistSoon();
358
+ }
359
+
360
+ choose(payload: ChatUiChoicePayload, queries: Readonly<Record<string, unknown>> = {}): { queued: boolean; turnId: string } {
361
+ const item = this.thread.get(payload.itemId);
362
+ const block =
363
+ item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
364
+ if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
365
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
366
+ }
367
+ const sameBlock = (origin: ChatUiChoiceOrigin | undefined) =>
368
+ origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
369
+ let previous = item.uiAnswers?.[block.id];
370
+ if (previous?.revision !== block.revision) {
371
+ previous = undefined;
372
+ }
373
+ // The message is logged first: a crash before its answer marker must not send it a second time.
374
+ if (!previous) {
375
+ const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
376
+ const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
377
+ const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
378
+ const turnId = sent?.turnId ?? queued?.turnId;
379
+ if (origin && turnId) {
380
+ previous = { ...origin, turnId, queued: !sent };
381
+ this.recordUiAnswer(previous);
382
+ }
383
+ }
384
+ if (previous) {
385
+ if (previous.choiceId !== payload.choiceId) {
386
+ throw new ChatError('ui-already-answered', 'This UI block was already answered');
387
+ }
388
+ return { queued: previous.queued, turnId: previous.turnId };
389
+ }
390
+ let selected;
391
+ try {
392
+ selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
393
+ } catch (error) {
394
+ throw new ChatError(
395
+ error instanceof UiFailure ? error.code : 'refused-choice',
396
+ error instanceof Error ? error.message : 'This choice could not be read'
397
+ );
398
+ }
399
+ const origin: ChatUiChoiceOrigin = {
400
+ itemId: item.id,
401
+ blockId: block.id,
402
+ revision: block.revision,
403
+ choiceId: payload.choiceId,
404
+ label: selected.label,
405
+ sourceAt: item.createdAt,
406
+ older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
407
+ values: selected.values,
408
+ at: Date.now()
409
+ };
410
+ const result = this.send(selected.context, { uiChoice: origin });
411
+ this.recordUiAnswer({ ...origin, ...result });
412
+ return result;
413
+ }
414
+
415
+ private recordUiAnswer(answer: ChatUiAnswer): void {
416
+ const item = this.thread.get(answer.itemId);
417
+ if (item?.kind !== 'assistant') {
418
+ return;
419
+ }
420
+ if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
421
+ return;
422
+ }
423
+ this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
424
+ this.options.persist();
425
+ }
426
+
282
427
  sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
283
428
  return this.submitInput(text, extras);
284
429
  }
@@ -415,7 +560,11 @@ export class ChatSession {
415
560
  this.sendAfterReplacement = false;
416
561
  this.pauseQueue(false);
417
562
  this.setQueue(rest);
418
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
563
+ this.dispatch(
564
+ next.text,
565
+ { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice },
566
+ next.turnId
567
+ );
419
568
  }
420
569
 
421
570
  private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
@@ -425,7 +574,10 @@ export class ChatSession {
425
574
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
426
575
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
427
576
  const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
428
- const said = [preamble, references.note].filter((part): part is string => part !== null);
577
+ const choiceNote = extras.uiChoice
578
+ ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
579
+ : null;
580
+ const said = [preamble, references.note, choiceNote].filter((part): part is string => part !== null);
429
581
  const input = {
430
582
  text,
431
583
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -544,14 +696,14 @@ export class ChatSession {
544
696
 
545
697
  /*
546
698
  * 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.
699
+ * The thread resets at once; the returned promise lets the host wait for pending attachment
700
+ * writes before removing their files. Writing the empty thread is the caller's.
549
701
  */
550
- clear(force: boolean): void {
702
+ clear(force: boolean): Promise<void> {
551
703
  if (this.busy && !force) {
552
704
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
553
705
  }
554
- void this.dispose();
706
+ const ended = this.dispose();
555
707
  this.generation = nextGeneration();
556
708
  this.projector.reset();
557
709
  // The fresh CLI hears what the host tells it at launch, as it would on a first turn.
@@ -590,6 +742,7 @@ export class ChatSession {
590
742
  }
591
743
  })
592
744
  ]);
745
+ return ended;
593
746
  }
594
747
 
595
748
  /*
@@ -994,6 +1147,7 @@ export class ChatSession {
994
1147
 
995
1148
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
996
1149
  freeze(): void {
1150
+ this.clearUi();
997
1151
  this.clearStartWarning();
998
1152
  this.clearWorkflowWarnings();
999
1153
  this.frozen = true;
@@ -1002,6 +1156,7 @@ export class ChatSession {
1002
1156
 
1003
1157
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
1004
1158
  dispose(): Promise<void> {
1159
+ this.clearUi();
1005
1160
  this.clearStartWarning();
1006
1161
  this.clearWorkflowWarnings();
1007
1162
  if (this.titleTimer !== null) {
@@ -1159,10 +1314,26 @@ export class ChatSession {
1159
1314
  const skills = extras.skills?.length ? extras.skills : undefined;
1160
1315
  const chats = extras.chats?.length ? extras.chats : undefined;
1161
1316
  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 }));
1317
+ events.push(
1318
+ this.thread.upsert({
1319
+ id: newId('user'),
1320
+ kind: 'user',
1321
+ createdAt: now,
1322
+ turnId,
1323
+ text,
1324
+ mentions,
1325
+ skills,
1326
+ chats,
1327
+ attachments,
1328
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1329
+ })
1330
+ );
1163
1331
  }
1164
1332
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
1165
1333
  this.emit(events);
1334
+ if (extras.uiChoice) {
1335
+ this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1336
+ }
1166
1337
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
1167
1338
  this.options.persist();
1168
1339
  return turnId;
@@ -1392,6 +1563,7 @@ export class ChatSession {
1392
1563
  runtimeMode: info.runtimeMode,
1393
1564
  resume: info.agentSessionId,
1394
1565
  generation,
1566
+ saveGeneratedImage: this.options.saveGeneratedImage,
1395
1567
  instructions: this.options.instructions?.() ?? null,
1396
1568
  resumeNote: this.options.resumeNote?.() ?? null,
1397
1569
  ...(folders.length > 0 ? { folders } : {}),
@@ -1689,7 +1861,7 @@ export class ChatSession {
1689
1861
  this.naming = true;
1690
1862
  const turnId = done[0]!.id;
1691
1863
  const answer = items
1692
- .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item.text : ''))
1864
+ .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
1693
1865
  .filter((text) => text !== '')
1694
1866
  .join('\n\n');
1695
1867
  const agentSessionId = this.thread.info.agentSessionId;
@@ -1743,9 +1915,129 @@ export class ChatSession {
1743
1915
 
1744
1916
  private emit(events: ChatEvent[]): void {
1745
1917
  const delegating = this.delegatingChange(events);
1746
- for (const event of delegating === null ? events : [...events, delegating]) {
1747
- this.options.emit(event);
1918
+ for (let event of delegating === null ? events : [...events, delegating]) {
1919
+ if (event.type === 'reset') {
1920
+ this.clearUi();
1921
+ }
1922
+ if (event.type === 'item' && event.item.kind === 'tool') {
1923
+ this.uiAttachments.delete(event.item.turnId);
1924
+ }
1925
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1926
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
1927
+ this.options.emit(event);
1928
+ continue;
1929
+ }
1930
+ const tail = this.unscannedUiText(item, event.type === 'item');
1931
+ if (uiMayReferenceHost(tail)) {
1932
+ this.uiReferencing.add(item.id);
1933
+ }
1934
+ let stream = this.uiStreams.get(item.id);
1935
+ if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
1936
+ this.forgetUiScan(item);
1937
+ this.options.emit(event);
1938
+ continue;
1939
+ }
1940
+ if (!stream) {
1941
+ stream = new UiStream({
1942
+ querySchemas: this.options.ui?.schemas,
1943
+ fenceLanguage: this.uiFenceLanguage,
1944
+ id: item.id,
1945
+ clock: this.options.clock ?? systemClock,
1946
+ emit: (preview) => {
1947
+ this.uiCompiled.set(item.id, preview);
1948
+ this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1949
+ }
1950
+ });
1951
+ this.uiStreams.set(item.id, stream);
1952
+ }
1953
+ if (this.uiReferencing.has(item.id)) {
1954
+ this.options.ui?.observe(item, false);
1955
+ }
1956
+ this.forgetUiScan(item);
1957
+ const latestAttachment = this.latestUiAttachment(item.turnId);
1958
+ if (event.type === 'item' && !item.streaming) {
1959
+ const ui = stream
1960
+ .finish(item.text, latestAttachment)
1961
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1962
+ this.uiStreams.delete(item.id);
1963
+ this.uiCompiled.delete(item.id);
1964
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1965
+ if (diagnostics.length > 0) {
1966
+ const repair =
1967
+ `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1968
+ 0,
1969
+ 1200
1970
+ );
1971
+ if (!this.pendingPreambles.includes(repair)) {
1972
+ this.pendingPreambles = [...this.pendingPreambles, repair];
1973
+ }
1974
+ }
1975
+ const current = this.thread.get(item.id);
1976
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1977
+ event = this.thread.upsert(completed);
1978
+ this.options.emit(event);
1979
+ this.options.ui?.observe(completed, true);
1980
+ } else {
1981
+ this.options.emit(event);
1982
+ stream.update(item.text, latestAttachment);
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ /*
1988
+ * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
1989
+ * is one line; a long line is cut short, behind a character no fence or tag can follow.
1990
+ */
1991
+ private unscannedUiText(item: ChatAssistantItem, whole: boolean): string {
1992
+ const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
1993
+ this.uiScanned.set(item.id, item.text.length);
1994
+ if (scanned === 0 || scanned > item.text.length) {
1995
+ return item.text;
1996
+ }
1997
+ const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
1998
+ const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
1999
+ return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
2000
+ }
2001
+
2002
+ private forgetUiScan(item: ChatAssistantItem): void {
2003
+ if (!item.streaming) {
2004
+ this.uiScanned.delete(item.id);
2005
+ this.uiReferencing.delete(item.id);
2006
+ }
2007
+ }
2008
+
2009
+ /* Looked up once per turn, and again only after one of its tool calls changed. */
2010
+ private latestUiAttachment(turnId: string | null): string | undefined {
2011
+ if (this.uiAttachments.has(turnId)) {
2012
+ return this.uiAttachments.get(turnId);
2013
+ }
2014
+ const attachment = this.findUiAttachment(turnId);
2015
+ this.uiAttachments.set(turnId, attachment);
2016
+ return attachment;
2017
+ }
2018
+
2019
+ private findUiAttachment(turnId: string | null): string | undefined {
2020
+ for (const item of this.thread.list().toReversed()) {
2021
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
2022
+ continue;
2023
+ }
2024
+ const input = GeneratedImageInputSchema.safeParse(item.input);
2025
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
2026
+ return input.data.attachment.id;
2027
+ }
2028
+ }
2029
+ return undefined;
2030
+ }
2031
+
2032
+ private clearUi(): void {
2033
+ for (const stream of this.uiStreams.values()) {
2034
+ stream.dispose();
1748
2035
  }
2036
+ this.uiStreams.clear();
2037
+ this.uiCompiled.clear();
2038
+ this.uiScanned.clear();
2039
+ this.uiReferencing.clear();
2040
+ this.uiAttachments.clear();
1749
2041
  }
1750
2042
 
1751
2043
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
@@ -20,10 +20,12 @@ const RecordSchema = z.looseObject({
20
20
  seq: z.number().int().nonnegative().optional(),
21
21
  resetSeq: z.number().int().nonnegative().optional(),
22
22
  // Never on the wire: what goes in front of the next real prompt, once (a fork's note for its agent).
23
- preambles: z.array(z.string()).optional()
23
+ preambles: z.array(z.string()).optional(),
24
+ // Never on the wire: the access a UI host captured for each reply that reads it, by item id.
25
+ uiAccess: z.record(z.string(), z.unknown()).optional()
24
26
  });
25
27
 
26
- const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles']);
28
+ const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
27
29
 
28
30
  /* What a host keeps in a chat's record beside the thread; it never reaches the wire. */
29
31
  export type ChatRecordExtras = Record<string, unknown>;
@@ -39,6 +41,7 @@ export interface ChatRecord {
39
41
  seq: number;
40
42
  resetSeq: number;
41
43
  preambles: string[];
44
+ uiAccess: Record<string, unknown>;
42
45
  extras: ChatRecordExtras;
43
46
  // The log as it is on disk, for the lines an attach with `since` may still be answered from.
44
47
  lines: ChatLogLine[];
@@ -54,8 +57,37 @@ function logName(chatId: string): string {
54
57
  return `${encodeURIComponent(chatId)}.log`;
55
58
  }
56
59
 
57
- function recordBody(info: ChatInfo, items: ChatItem[], at: ChatSeq, preambles: readonly string[], extras: ChatRecordExtras): string {
58
- return JSON.stringify({ info, items, ...at, ...(preambles.length === 0 ? {} : { preambles }), ...withoutOwnKeys(extras) });
60
+ function recordBody(
61
+ info: ChatInfo,
62
+ items: ChatItem[],
63
+ at: ChatSeq,
64
+ preambles: readonly string[],
65
+ extras: ChatRecordExtras,
66
+ uiAccess: Readonly<Record<string, unknown>>
67
+ ): string {
68
+ return JSON.stringify({
69
+ info,
70
+ items,
71
+ ...at,
72
+ ...(preambles.length === 0 ? {} : { preambles }),
73
+ ...(Object.keys(uiAccess).length === 0 ? {} : { uiAccess }),
74
+ ...withoutOwnKeys(extras)
75
+ });
76
+ }
77
+
78
+ /* A record from before `uiAccess` kept the access inside each reply's `uiQueries`, which the item schema now drops. */
79
+ function liftUiAccess(record: unknown): unknown {
80
+ if (typeof record !== 'object' || record === null || 'uiAccess' in record || !Array.isArray((record as { items?: unknown }).items)) {
81
+ return record;
82
+ }
83
+ const uiAccess: Record<string, unknown> = {};
84
+ for (const item of (record as { items: unknown[] }).items) {
85
+ const queries = (item as { uiQueries?: unknown } | null)?.uiQueries;
86
+ if (typeof queries === 'object' && queries !== null && 'access' in queries) {
87
+ uiAccess[String((item as { id: unknown }).id)] = queries.access;
88
+ }
89
+ }
90
+ return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
59
91
  }
60
92
 
61
93
  function withoutOwnKeys(extras: ChatRecordExtras): ChatRecordExtras {
@@ -127,9 +159,10 @@ export class ChatStore {
127
159
  items: ChatItem[],
128
160
  at: ChatSeq = { seq: 0, resetSeq: 0 },
129
161
  preambles: readonly string[] = [],
130
- extras: ChatRecordExtras = {}
162
+ extras: ChatRecordExtras = {},
163
+ uiAccess: Readonly<Record<string, unknown>> = {}
131
164
  ): Promise<number> {
132
- const body = recordBody(info, items, at, preambles, extras);
165
+ const body = recordBody(info, items, at, preambles, extras, uiAccess);
133
166
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
134
167
  await writeAtomic(join(this.dir, recordFileName(chatId)), body);
135
168
  return body.length;
@@ -145,10 +178,11 @@ export class ChatStore {
145
178
  items: ChatItem[],
146
179
  at: ChatSeq = { seq: 0, resetSeq: 0 },
147
180
  preambles: readonly string[] = [],
148
- extras: ChatRecordExtras = {}
181
+ extras: ChatRecordExtras = {},
182
+ uiAccess: Readonly<Record<string, unknown>> = {}
149
183
  ): void {
150
184
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
151
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras));
185
+ writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
152
186
  }
153
187
 
154
188
  /* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
@@ -174,7 +208,15 @@ export class ChatStore {
174
208
  resetSeq = line.seq;
175
209
  }
176
210
  }
177
- return { ...thread.snapshot(), seq, resetSeq, preambles: snapshot.preambles ?? [], extras: extrasOf(snapshot), lines };
211
+ return {
212
+ ...thread.snapshot(),
213
+ seq,
214
+ resetSeq,
215
+ preambles: snapshot.preambles ?? [],
216
+ uiAccess: snapshot.uiAccess ?? {},
217
+ extras: extrasOf(snapshot),
218
+ lines
219
+ };
178
220
  }
179
221
 
180
222
  /*
@@ -204,7 +246,7 @@ export class ChatStore {
204
246
  await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
205
247
  }
206
248
  }
207
- const parsed = RecordSchema.safeParse(record);
249
+ const parsed = RecordSchema.safeParse(liftUiAccess(record));
208
250
  return parsed.success ? parsed.data : UNREADABLE;
209
251
  }
210
252
 
@@ -260,5 +302,5 @@ function fromLogAlone(lines: ChatLogLine[]): ChatRecord | null {
260
302
  resetSeq = line.seq;
261
303
  }
262
304
  }
263
- return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], extras: {}, lines };
305
+ return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], uiAccess: {}, extras: {}, lines };
264
306
  }
@@ -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
  }