@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.
- package/dist/chat/attachment-store.d.ts +2 -0
- package/dist/chat/attachment-store.js +38 -2
- package/dist/chat/backend.d.ts +12 -1
- package/dist/chat/chat-core.d.ts +57 -1
- package/dist/chat/chat-core.js +67 -14
- package/dist/chat/chat-session.d.ts +35 -2
- package/dist/chat/chat-session.js +261 -11
- package/dist/chat/chat-store.d.ts +3 -2
- package/dist/chat/chat-store.js +42 -11
- package/dist/chat/codex-backend.d.ts +2 -0
- package/dist/chat/codex-backend.js +51 -1
- package/dist/chat/codex-protocol.js +30 -3
- package/dist/chat/delta-coalescer.js +1 -1
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/fake-codex.js +33 -0
- package/dist/chat/generated-image.d.ts +6 -0
- package/dist/chat/generated-image.js +74 -0
- package/dist/chat/projector.js +9 -2
- package/dist/chat/readable-text.d.ts +2 -0
- package/dist/chat/readable-text.js +16 -0
- package/dist/chat/thread.js +6 -0
- package/dist/chat/ui-queries.d.ts +49 -0
- package/dist/chat/ui-queries.js +358 -0
- package/dist/host/handlers.js +3 -0
- package/dist/tasks/task-coordinator.js +6 -2
- package/package.json +5 -4
- package/src/chat/attachment-store.ts +38 -2
- package/src/chat/backend.ts +14 -2
- package/src/chat/chat-core.ts +84 -13
- package/src/chat/chat-session.ts +304 -12
- package/src/chat/chat-store.ts +53 -11
- package/src/chat/codex-backend.ts +50 -1
- package/src/chat/codex-protocol.ts +31 -3
- package/src/chat/delta-coalescer.ts +1 -1
- package/src/chat/errors.ts +2 -0
- package/src/chat/fake-codex.ts +34 -0
- package/src/chat/generated-image.ts +88 -0
- package/src/chat/projector.ts +9 -2
- package/src/chat/readable-text.ts +18 -0
- package/src/chat/thread.ts +6 -0
- package/src/chat/ui-queries.ts +416 -0
- package/src/host/handlers.ts +3 -0
- package/src/tasks/task-coordinator.ts +6 -2
package/src/chat/chat-session.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
*
|
|
548
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
1747
|
-
|
|
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. */
|
package/src/chat/chat-store.ts
CHANGED
|
@@ -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(
|
|
58
|
-
|
|
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 {
|
|
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()
|
|
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
|
}
|