@adecore/agents 0.20.0 → 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.
- package/dist/chat/attachment-store.d.ts +2 -0
- package/dist/chat/attachment-store.js +38 -2
- package/dist/chat/backend.d.ts +13 -1
- package/dist/chat/chat-core.d.ts +53 -1
- package/dist/chat/chat-core.js +53 -10
- package/dist/chat/chat-session.d.ts +29 -2
- package/dist/chat/chat-session.js +258 -15
- package/dist/chat/claude-backend.d.ts +4 -0
- package/dist/chat/claude-backend.js +39 -0
- package/dist/chat/claude-protocol.d.ts +2 -0
- package/dist/chat/claude-protocol.js +12 -1
- package/dist/chat/codex-backend.d.ts +4 -0
- package/dist/chat/codex-backend.js +77 -5
- package/dist/chat/codex-protocol.d.ts +1 -0
- package/dist/chat/codex-protocol.js +74 -5
- 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/thread.js +6 -0
- package/dist/chat/ui-queries.d.ts +38 -0
- package/dist/chat/ui-queries.js +311 -0
- package/dist/host/handlers.js +4 -1
- package/package.json +5 -4
- package/src/chat/attachment-store.ts +38 -2
- package/src/chat/backend.ts +16 -2
- package/src/chat/chat-core.ts +60 -9
- package/src/chat/chat-session.ts +294 -16
- package/src/chat/claude-backend.ts +40 -0
- package/src/chat/claude-protocol.ts +16 -1
- package/src/chat/codex-backend.ts +77 -5
- package/src/chat/codex-protocol.ts +78 -6
- 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/thread.ts +6 -0
- package/src/chat/ui-queries.ts +360 -0
- package/src/host/handlers.ts +9 -1
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, 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,8 @@ export interface LimitResumeHooks {
|
|
|
98
111
|
const TITLE_RECHECK_MS = 10_000;
|
|
99
112
|
|
|
100
113
|
export interface ChatSendExtras {
|
|
114
|
+
uiChoice?: ChatUiChoiceOrigin;
|
|
115
|
+
delivery?: 'steer' | 'queue';
|
|
101
116
|
mentions?: string[];
|
|
102
117
|
skills?: string[];
|
|
103
118
|
chats?: string[];
|
|
@@ -175,6 +190,9 @@ export class ChatSession {
|
|
|
175
190
|
private readonly orphans = new Set<string>();
|
|
176
191
|
private pendingPreambles: readonly string[];
|
|
177
192
|
private submission: { turnId: string; input: TurnInput } | null = null;
|
|
193
|
+
private readonly steering = new Set<string>();
|
|
194
|
+
private readonly uiStreams = new Map<string, UiStream>();
|
|
195
|
+
private readonly uiCompiled = new Map<string, UiStreamPreview>();
|
|
178
196
|
|
|
179
197
|
constructor(options: ChatSessionOptions) {
|
|
180
198
|
this.options = options;
|
|
@@ -183,6 +201,24 @@ export class ChatSession {
|
|
|
183
201
|
this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
|
|
184
202
|
}
|
|
185
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
|
+
|
|
186
222
|
get id(): string {
|
|
187
223
|
return this.thread.info.chatId;
|
|
188
224
|
}
|
|
@@ -249,9 +285,7 @@ export class ChatSession {
|
|
|
249
285
|
}
|
|
250
286
|
|
|
251
287
|
/*
|
|
252
|
-
*
|
|
253
|
-
* that turn settles. One queue for both providers: Claude's steer and Codex's own queue have
|
|
254
|
-
* different semantics, and one rule is easier to reason about than a rule per CLI.
|
|
288
|
+
* Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
|
|
255
289
|
*/
|
|
256
290
|
send(text: string, extras: ChatSendExtras = {}): { queued: boolean; turnId: string } {
|
|
257
291
|
const turnId = newId('turn');
|
|
@@ -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,11 +314,153 @@ 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
|
+
|
|
394
|
+
sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
|
|
395
|
+
return this.submitInput(text, extras);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async submitInput(text: string, extras: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
|
|
399
|
+
const backend = this.backend;
|
|
400
|
+
const turnId = this.info.activeTurnId;
|
|
401
|
+
if (
|
|
402
|
+
extras.delivery !== 'steer' ||
|
|
403
|
+
this.info.queuePaused ||
|
|
404
|
+
text.startsWith('/') ||
|
|
405
|
+
!this.busy ||
|
|
406
|
+
!turnId ||
|
|
407
|
+
!backend?.steerTurn ||
|
|
408
|
+
this.pendingStart ||
|
|
409
|
+
this.replacementWaits()
|
|
410
|
+
) {
|
|
411
|
+
return this.send(text, extras);
|
|
412
|
+
}
|
|
413
|
+
const queued = this.send(text, extras);
|
|
414
|
+
const message = this.queue.find((entry) => entry.turnId === queued.turnId);
|
|
415
|
+
if (!message) {
|
|
416
|
+
return queued;
|
|
417
|
+
}
|
|
418
|
+
this.steering.add(message.id);
|
|
419
|
+
try {
|
|
420
|
+
const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
|
|
421
|
+
const accepted = await backend.steerTurn({
|
|
422
|
+
promptId: message.id,
|
|
423
|
+
text,
|
|
424
|
+
preamble: references.note,
|
|
425
|
+
attachments: extras.attachments ?? [],
|
|
426
|
+
mentions: extras.mentions ?? [],
|
|
427
|
+
skills: extras.skills ?? []
|
|
428
|
+
});
|
|
429
|
+
if (!accepted) {
|
|
430
|
+
return queued;
|
|
431
|
+
}
|
|
432
|
+
this.emit([
|
|
433
|
+
this.thread.upsert({
|
|
434
|
+
id: `user-${message.id}`,
|
|
435
|
+
kind: 'user',
|
|
436
|
+
createdAt: message.createdAt,
|
|
437
|
+
turnId,
|
|
438
|
+
text,
|
|
439
|
+
mentions: extras.mentions,
|
|
440
|
+
skills: extras.skills,
|
|
441
|
+
chats: references.ids,
|
|
442
|
+
attachments: extras.attachments
|
|
443
|
+
}),
|
|
444
|
+
this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
|
|
445
|
+
]);
|
|
446
|
+
this.options.persist();
|
|
447
|
+
return { queued: false, turnId };
|
|
448
|
+
} catch (error) {
|
|
449
|
+
// A lost acknowledgement does not prove rejection. Keep the message for an explicit retry.
|
|
450
|
+
this.pauseQueue(true);
|
|
451
|
+
this.options.persist();
|
|
452
|
+
throw error;
|
|
453
|
+
} finally {
|
|
454
|
+
this.steering.delete(message.id);
|
|
455
|
+
this.drainQueue();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
282
459
|
/* Drops a queued message and hands it back; null when nothing waits under that id, as once it went out. */
|
|
283
460
|
unqueue(messageId: string): ChatQueuedMessage | null {
|
|
284
461
|
const queue = this.queue;
|
|
285
462
|
const message = queue.find((entry) => entry.id === messageId);
|
|
286
|
-
if (!message) {
|
|
463
|
+
if (!message || this.steering.has(message.id)) {
|
|
287
464
|
return null;
|
|
288
465
|
}
|
|
289
466
|
this.setQueue(queue.filter((entry) => entry.id !== messageId));
|
|
@@ -294,7 +471,7 @@ export class ChatSession {
|
|
|
294
471
|
sendNow(messageId: string): boolean {
|
|
295
472
|
const queue = this.queue;
|
|
296
473
|
const message = queue.find((entry) => entry.id === messageId);
|
|
297
|
-
if (!message) {
|
|
474
|
+
if (!message || this.steering.has(message.id)) {
|
|
298
475
|
return false;
|
|
299
476
|
}
|
|
300
477
|
this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
|
|
@@ -323,6 +500,7 @@ export class ChatSession {
|
|
|
323
500
|
const explicit = asked || this.sendAfterReplacement;
|
|
324
501
|
if (
|
|
325
502
|
!next ||
|
|
503
|
+
this.steering.size > 0 ||
|
|
326
504
|
this.busy ||
|
|
327
505
|
(!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))
|
|
328
506
|
) {
|
|
@@ -349,7 +527,11 @@ export class ChatSession {
|
|
|
349
527
|
this.sendAfterReplacement = false;
|
|
350
528
|
this.pauseQueue(false);
|
|
351
529
|
this.setQueue(rest);
|
|
352
|
-
this.dispatch(
|
|
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
|
+
);
|
|
353
535
|
}
|
|
354
536
|
|
|
355
537
|
private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
|
|
@@ -359,7 +541,10 @@ export class ChatSession {
|
|
|
359
541
|
// A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
|
|
360
542
|
const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
|
|
361
543
|
const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
|
|
362
|
-
const
|
|
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);
|
|
363
548
|
const input = {
|
|
364
549
|
text,
|
|
365
550
|
preamble: said.length === 0 ? null : said.join('\n\n'),
|
|
@@ -478,14 +663,14 @@ export class ChatSession {
|
|
|
478
663
|
|
|
479
664
|
/*
|
|
480
665
|
* Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
|
|
481
|
-
*
|
|
482
|
-
*
|
|
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.
|
|
483
668
|
*/
|
|
484
|
-
clear(force: boolean): void {
|
|
669
|
+
clear(force: boolean): Promise<void> {
|
|
485
670
|
if (this.busy && !force) {
|
|
486
671
|
throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
|
|
487
672
|
}
|
|
488
|
-
|
|
673
|
+
const ended = this.dispose();
|
|
489
674
|
this.generation = nextGeneration();
|
|
490
675
|
this.projector.reset();
|
|
491
676
|
// The fresh CLI hears what the host tells it at launch, as it would on a first turn.
|
|
@@ -524,6 +709,7 @@ export class ChatSession {
|
|
|
524
709
|
}
|
|
525
710
|
})
|
|
526
711
|
]);
|
|
712
|
+
return ended;
|
|
527
713
|
}
|
|
528
714
|
|
|
529
715
|
/*
|
|
@@ -928,6 +1114,7 @@ export class ChatSession {
|
|
|
928
1114
|
|
|
929
1115
|
/* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
|
|
930
1116
|
freeze(): void {
|
|
1117
|
+
this.clearUi();
|
|
931
1118
|
this.clearStartWarning();
|
|
932
1119
|
this.clearWorkflowWarnings();
|
|
933
1120
|
this.frozen = true;
|
|
@@ -936,6 +1123,7 @@ export class ChatSession {
|
|
|
936
1123
|
|
|
937
1124
|
/* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
|
|
938
1125
|
dispose(): Promise<void> {
|
|
1126
|
+
this.clearUi();
|
|
939
1127
|
this.clearStartWarning();
|
|
940
1128
|
this.clearWorkflowWarnings();
|
|
941
1129
|
if (this.titleTimer !== null) {
|
|
@@ -1093,10 +1281,26 @@ export class ChatSession {
|
|
|
1093
1281
|
const skills = extras.skills?.length ? extras.skills : undefined;
|
|
1094
1282
|
const chats = extras.chats?.length ? extras.chats : undefined;
|
|
1095
1283
|
const attachments = extras.attachments?.length ? extras.attachments : undefined;
|
|
1096
|
-
events.push(
|
|
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
|
+
);
|
|
1097
1298
|
}
|
|
1098
1299
|
events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
|
|
1099
1300
|
this.emit(events);
|
|
1301
|
+
if (extras.uiChoice) {
|
|
1302
|
+
this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
|
|
1303
|
+
}
|
|
1100
1304
|
// On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
|
|
1101
1305
|
this.options.persist();
|
|
1102
1306
|
return turnId;
|
|
@@ -1326,6 +1530,7 @@ export class ChatSession {
|
|
|
1326
1530
|
runtimeMode: info.runtimeMode,
|
|
1327
1531
|
resume: info.agentSessionId,
|
|
1328
1532
|
generation,
|
|
1533
|
+
saveGeneratedImage: this.options.saveGeneratedImage,
|
|
1329
1534
|
instructions: this.options.instructions?.() ?? null,
|
|
1330
1535
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1331
1536
|
...(folders.length > 0 ? { folders } : {}),
|
|
@@ -1677,9 +1882,82 @@ export class ChatSession {
|
|
|
1677
1882
|
|
|
1678
1883
|
private emit(events: ChatEvent[]): void {
|
|
1679
1884
|
const delegating = this.delegatingChange(events);
|
|
1680
|
-
for (
|
|
1681
|
-
|
|
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();
|
|
1682
1958
|
}
|
|
1959
|
+
this.uiStreams.clear();
|
|
1960
|
+
this.uiCompiled.clear();
|
|
1683
1961
|
}
|
|
1684
1962
|
|
|
1685
1963
|
/* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
|
|
@@ -27,6 +27,8 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
27
27
|
private stdinClosed = false;
|
|
28
28
|
// The person asked to stop; the result frame that follows closes the turn as aborted.
|
|
29
29
|
private interrupted = false;
|
|
30
|
+
private readonly steers = new Map<string, { resolve(accepted: boolean): void; reject(reason: Error): void }>();
|
|
31
|
+
private active = false;
|
|
30
32
|
|
|
31
33
|
constructor(launch: BackendLaunch, host: BackendHost, options: ClaudeBackendOptions = {}) {
|
|
32
34
|
this.launch = launch;
|
|
@@ -72,10 +74,29 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
72
74
|
// The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
|
|
73
75
|
const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
|
|
74
76
|
const promptId = input.promptId ?? randomUUID();
|
|
77
|
+
this.active = true;
|
|
75
78
|
this.protocol.beginPrompt(promptId);
|
|
76
79
|
this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
steerTurn(input: TurnInput): Promise<boolean> {
|
|
83
|
+
if (!this.child || !this.active || this.stdinClosed) {
|
|
84
|
+
return Promise.resolve(false);
|
|
85
|
+
}
|
|
86
|
+
const promptId = input.promptId ?? randomUUID();
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
this.steers.set(promptId, { resolve, reject });
|
|
89
|
+
this.protocol.beginSteer(promptId);
|
|
90
|
+
try {
|
|
91
|
+
const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
|
|
92
|
+
this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.steers.delete(promptId);
|
|
95
|
+
reject(error instanceof Error ? error : new Error(errorText(error)));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
/* Claude Code folds its context through a slash command, so the session sends it as a turn. */
|
|
80
101
|
compact(): void {
|
|
81
102
|
this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
|
|
@@ -120,6 +141,7 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
120
141
|
}
|
|
121
142
|
|
|
122
143
|
dispose(): Promise<void> {
|
|
144
|
+
this.rejectSteers('Claude stopped before acknowledging your message');
|
|
123
145
|
const child = this.child;
|
|
124
146
|
this.child = null;
|
|
125
147
|
return child?.end() ?? Promise.resolve();
|
|
@@ -165,7 +187,15 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
165
187
|
} catch {
|
|
166
188
|
return;
|
|
167
189
|
}
|
|
190
|
+
const echoed = typeof frame === 'object' && frame !== null ? (frame as Record<string, unknown>) : null;
|
|
191
|
+
if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
|
|
192
|
+
this.steers.get(echoed.uuid)?.resolve(true);
|
|
193
|
+
this.steers.delete(echoed.uuid);
|
|
194
|
+
}
|
|
168
195
|
for (const event of this.protocol.handle(frame)) {
|
|
196
|
+
if (event.type === 'turn.done') {
|
|
197
|
+
this.active = false;
|
|
198
|
+
}
|
|
169
199
|
if (event.type === 'turn.done' && this.interrupted) {
|
|
170
200
|
this.interrupted = false;
|
|
171
201
|
this.host.onEvent({ ...event, state: 'aborted' });
|
|
@@ -183,7 +213,16 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
183
213
|
}
|
|
184
214
|
}
|
|
185
215
|
|
|
216
|
+
private rejectSteers(reason: string): void {
|
|
217
|
+
this.active = false;
|
|
218
|
+
for (const pending of this.steers.values()) {
|
|
219
|
+
pending.reject(new Error(reason));
|
|
220
|
+
}
|
|
221
|
+
this.steers.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
186
224
|
private failed(error: unknown): void {
|
|
225
|
+
this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
|
|
187
226
|
this.protocol.forgetPending();
|
|
188
227
|
try {
|
|
189
228
|
this.host.onEvent({ type: 'failed', message: `Claude transport failed: ${errorText(error)}`, processAlive: this.running });
|
|
@@ -229,6 +268,7 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
229
268
|
return;
|
|
230
269
|
}
|
|
231
270
|
this.child = null;
|
|
271
|
+
this.rejectSteers('Claude exited before acknowledging your message');
|
|
232
272
|
this.protocol.forgetPending();
|
|
233
273
|
this.host.onEvent({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
|
|
234
274
|
}
|
|
@@ -147,7 +147,14 @@ export class ClaudeProtocol {
|
|
|
147
147
|
private apiError: string | null = null;
|
|
148
148
|
private readonly refusedWindows = new Map<string, number | null>();
|
|
149
149
|
|
|
150
|
+
private readonly steerIds = new Set<string>();
|
|
151
|
+
|
|
152
|
+
beginSteer(promptId: string): void {
|
|
153
|
+
this.steerIds.add(promptId);
|
|
154
|
+
}
|
|
155
|
+
|
|
150
156
|
beginPrompt(promptId: string): void {
|
|
157
|
+
this.steerIds.clear();
|
|
151
158
|
this.promptId = promptId;
|
|
152
159
|
this.apiError = null;
|
|
153
160
|
this.refusedWindows.clear();
|
|
@@ -575,7 +582,12 @@ export class ClaudeProtocol {
|
|
|
575
582
|
private handleResult(frame: Frame, events: BackendEvent[]): void {
|
|
576
583
|
const echoed = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids.filter((value): value is string => typeof value === 'string') : [];
|
|
577
584
|
const origin = str(frame.origin) ?? (isRecord(frame.origin) ? str(frame.origin.kind) : null);
|
|
578
|
-
if (
|
|
585
|
+
if (
|
|
586
|
+
this.promptId !== null &&
|
|
587
|
+
(echoed.length > 0
|
|
588
|
+
? !echoed.includes(this.promptId) && !echoed.some((id) => this.steerIds.has(id))
|
|
589
|
+
: origin !== null && origin !== 'human' && origin !== 'user')
|
|
590
|
+
) {
|
|
579
591
|
if (typeof frame.total_cost_usd === 'number') {
|
|
580
592
|
events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
|
|
581
593
|
}
|
|
@@ -586,6 +598,9 @@ export class ClaudeProtocol {
|
|
|
586
598
|
}
|
|
587
599
|
const promptId = this.promptId !== null && echoed.includes(this.promptId) ? this.promptId : null;
|
|
588
600
|
this.promptId = null;
|
|
601
|
+
for (const id of echoed) {
|
|
602
|
+
this.steerIds.delete(id);
|
|
603
|
+
}
|
|
589
604
|
const failed = frame.is_error === true || (typeof frame.subtype === 'string' && frame.subtype.startsWith('error'));
|
|
590
605
|
const errors = Array.isArray(frame.errors) ? frame.errors.filter((error): error is string => typeof error === 'string') : [];
|
|
591
606
|
const lastUuid = this.lastUuid;
|