@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { extname, join } from 'node:path';
|
|
4
4
|
import { attachmentImageMime, type ChatAttachment, type ChatAttachmentUpload } from '@adecore/agent-contracts';
|
|
5
5
|
|
|
@@ -57,6 +57,42 @@ export class AttachmentStore {
|
|
|
57
57
|
return { id, name: upload.name, mime, size: bytes.byteLength, path };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
async saveGenerated(chatId: string, ref: string, upload: ChatAttachmentUpload): Promise<ChatAttachment> {
|
|
61
|
+
const id = createHash('sha256').update(`codex-image:${ref}`).digest('hex');
|
|
62
|
+
const bytes = Buffer.from(upload.data, 'base64');
|
|
63
|
+
const folder = this.folderOf(chatId);
|
|
64
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
65
|
+
const path = join(folder, `${id}.${extensionFor('', upload.mime)}`);
|
|
66
|
+
const temporary = join(folder, `.${id}-${randomBytes(8).toString('hex')}.tmp`);
|
|
67
|
+
try {
|
|
68
|
+
await writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
|
69
|
+
try {
|
|
70
|
+
// Publish complete bytes without replacing an image another replay has already saved.
|
|
71
|
+
await link(temporary, path);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
const previous = await readFile(path);
|
|
77
|
+
if (!previous.equals(bytes)) {
|
|
78
|
+
throw new Error('The generated image id already belongs to another image');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(temporary, { force: true });
|
|
83
|
+
}
|
|
84
|
+
return { id, name: upload.name, mime: upload.mime, size: bytes.byteLength, path };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async copy(chatId: string, attachment: ChatAttachment): Promise<ChatAttachment> {
|
|
88
|
+
const bytes = await readFile(attachment.path);
|
|
89
|
+
const folder = this.folderOf(chatId);
|
|
90
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
91
|
+
const path = join(folder, `${createHash('sha256').update(attachment.id).digest('hex')}.${extensionFor(attachment.name, attachment.mime)}`);
|
|
92
|
+
await writeFile(path, bytes, { mode: 0o600 });
|
|
93
|
+
return { ...attachment, path };
|
|
94
|
+
}
|
|
95
|
+
|
|
60
96
|
/* Everything a chat attached; used when the chat itself is deleted. */
|
|
61
97
|
async removeAll(chatId: string): Promise<void> {
|
|
62
98
|
await rm(this.folderOf(chatId), { recursive: true, force: true });
|
package/src/chat/backend.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ChatAttachment,
|
|
3
|
+
ChatAttachmentUpload,
|
|
3
4
|
ChatFileChange,
|
|
4
5
|
ChatQuestion,
|
|
5
6
|
ChatSkill,
|
|
@@ -42,6 +43,7 @@ export interface BackendLaunch {
|
|
|
42
43
|
folders?: readonly string[];
|
|
43
44
|
// How the CLI is started; a test runs a fake in the same process, everything else spawns it.
|
|
44
45
|
spawn?: SpawnChatProcess;
|
|
46
|
+
saveGeneratedImage?(ref: string, upload: ChatAttachmentUpload): Promise<ChatAttachment>;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface TurnInput {
|
|
@@ -78,9 +80,19 @@ export type BackendEvent =
|
|
|
78
80
|
// The CLI renamed its thread, for a protocol that says so.
|
|
79
81
|
| { type: 'title'; title: string }
|
|
80
82
|
| { type: 'permissions'; permissionMode: string; effectiveRuntimeMode?: RuntimeMode }
|
|
81
|
-
| { type: 'text.delta'; ref: string; text: string }
|
|
83
|
+
| { type: 'text.delta'; ref: string; text: string; phase?: string }
|
|
82
84
|
// `parentRef` is set for text a subagent wrote; it belongs to that agent's row, not to the thread.
|
|
83
|
-
| { type: 'text.done'; ref: string; text: string; parentRef?: string | null }
|
|
85
|
+
| { type: 'text.done'; ref: string; text: string; parentRef?: string | null; phase?: string }
|
|
86
|
+
// Bytes are consumed by the backend before anything reaches the thread or its log.
|
|
87
|
+
| {
|
|
88
|
+
type: 'image.generated';
|
|
89
|
+
ref: string;
|
|
90
|
+
upload: ChatAttachmentUpload;
|
|
91
|
+
width?: number;
|
|
92
|
+
height?: number;
|
|
93
|
+
revisedPrompt?: string;
|
|
94
|
+
transparentBackground?: boolean;
|
|
95
|
+
}
|
|
84
96
|
// What the model thought before it answered; consecutive blocks become one thinking item.
|
|
85
97
|
| { type: 'thinking.delta'; ref: string; text: string }
|
|
86
98
|
| { type: 'thinking.done'; ref: string; text: string }
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
ChatAttachmentUploadsSchema,
|
|
5
|
+
generatedImageAttachment,
|
|
5
6
|
type AgentKind,
|
|
6
7
|
type ChatAttachment,
|
|
7
8
|
type ChatAttachResult,
|
|
@@ -15,6 +16,9 @@ import {
|
|
|
15
16
|
type ChatInfo,
|
|
16
17
|
type ChatItem,
|
|
17
18
|
type ChatQueuedMessage,
|
|
19
|
+
type ChatUiChoicePayload,
|
|
20
|
+
type ChatUiQueryPayload,
|
|
21
|
+
type ChatUiLinkPayload,
|
|
18
22
|
type ChatSkill,
|
|
19
23
|
type ChatSubagentPayload,
|
|
20
24
|
type ChatSubagentResult,
|
|
@@ -23,6 +27,7 @@ import {
|
|
|
23
27
|
type ModelSelection,
|
|
24
28
|
type RuntimeMode
|
|
25
29
|
} from '@adecore/agent-contracts';
|
|
30
|
+
import { ChatUiQueries, type ChatUiHost } from './ui-queries.ts';
|
|
26
31
|
import { ClientSinks } from '../client-sinks.ts';
|
|
27
32
|
import { errorText } from '../error-text.ts';
|
|
28
33
|
import type { AgentEvent, AgentSink } from '../events.ts';
|
|
@@ -52,6 +57,7 @@ import type { CodexProcessSpec } from './codex-thread.ts';
|
|
|
52
57
|
import type { CodexClientInfo } from './codex-transport.ts';
|
|
53
58
|
import { ComposerPreferences } from './composer-preferences.ts';
|
|
54
59
|
import { DeltaCoalescer } from './delta-coalescer.ts';
|
|
60
|
+
import { readableAssistantText } from './readable-text.ts';
|
|
55
61
|
import { ChatError } from './errors.ts';
|
|
56
62
|
import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
|
|
57
63
|
import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
|
|
@@ -79,7 +85,8 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
|
|
|
79
85
|
/* The start of a message on one line; a message of only attachments is its file names. */
|
|
80
86
|
function excerptOf(item: BookmarkableItem): string {
|
|
81
87
|
const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
|
|
82
|
-
|
|
88
|
+
const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
|
|
89
|
+
return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
// One process worked on the turn and one more may take it up after a restart; a loop of resumes could redo a command forever.
|
|
@@ -96,6 +103,9 @@ const DEFAULT_RESUME_WORDS: ResumeWords = {
|
|
|
96
103
|
};
|
|
97
104
|
|
|
98
105
|
export interface ChatCoreOptions {
|
|
106
|
+
intelligentUi?: ChatUiHost;
|
|
107
|
+
// The info string that marks a UI fence in a reply, for a host that keeps a name of its own.
|
|
108
|
+
uiFenceLanguage?: string;
|
|
99
109
|
providers: ProviderRegistry;
|
|
100
110
|
// Where the threads are kept; without it a chat lives as long as the process.
|
|
101
111
|
store?: ChatStore;
|
|
@@ -168,6 +178,8 @@ export class ChatCore {
|
|
|
168
178
|
/* Where Claude Code keeps the projects of its default account on this machine; empty when it has none. */
|
|
169
179
|
readonly claudeProjectsDir: string;
|
|
170
180
|
readonly composerPreferences = new ComposerPreferences();
|
|
181
|
+
private readonly uiQueries: ChatUiQueries | null;
|
|
182
|
+
private readonly uiFenceLanguage: string | undefined;
|
|
171
183
|
private readonly checkpoints: TurnCheckpoints | null;
|
|
172
184
|
private readonly instructions: string | null;
|
|
173
185
|
private readonly skillIndex: SkillIndex;
|
|
@@ -181,7 +193,7 @@ export class ChatCore {
|
|
|
181
193
|
// How big each record was the last time it went to disk, and the writes waiting for a big one.
|
|
182
194
|
private readonly sizes = new Map<string, number>();
|
|
183
195
|
private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
|
|
184
|
-
// What the last record held that no event carries (preambles, the host's extras), as its JSON.
|
|
196
|
+
// What the last record held that no event carries (preambles, the host's extras, captured UI access), as its JSON.
|
|
185
197
|
private readonly unlogged = new Map<string, string>();
|
|
186
198
|
// The write in flight per chat, so the next one queues behind it instead of racing it.
|
|
187
199
|
private readonly writes = new Map<string, Promise<void>>();
|
|
@@ -194,6 +206,8 @@ export class ChatCore {
|
|
|
194
206
|
private readonly limitResume: ChatCoreOptions['limitResume'] | null;
|
|
195
207
|
|
|
196
208
|
constructor(options: ChatCoreOptions) {
|
|
209
|
+
this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
|
|
210
|
+
this.uiFenceLanguage = options.uiFenceLanguage;
|
|
197
211
|
this.providers = options.providers;
|
|
198
212
|
this.store = options.store ?? null;
|
|
199
213
|
this.bookmarks = options.bookmarks ?? null;
|
|
@@ -390,8 +404,11 @@ export class ChatCore {
|
|
|
390
404
|
const references = this.referencesFor(payload.chatId);
|
|
391
405
|
const session = new ChatSession({
|
|
392
406
|
info,
|
|
407
|
+
...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
|
|
408
|
+
...(this.uiQueries ? { ui: { schemas: this.uiQueries.schemas, observe: (item, final) => this.uiQueries!.observe(session, item, final) } } : {}),
|
|
393
409
|
items: stored?.items ?? [],
|
|
394
410
|
preambles: stored?.preambles ?? [],
|
|
411
|
+
uiAccess: stored?.uiAccess ?? {},
|
|
395
412
|
provider,
|
|
396
413
|
command: this.commands[kind] ?? provider.command,
|
|
397
414
|
...(this.spawn ? { spawn: this.spawn } : {}),
|
|
@@ -400,6 +417,7 @@ export class ChatCore {
|
|
|
400
417
|
this.accounts?.launched?.(kind, chatAccount);
|
|
401
418
|
return env;
|
|
402
419
|
},
|
|
420
|
+
saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
|
|
403
421
|
instructions: () => this.instructionsFor(payload.chatId),
|
|
404
422
|
resumeNote: () => this.resumeNoteFor(payload.chatId),
|
|
405
423
|
folders: () => this.foldersFor(payload.chatId),
|
|
@@ -495,11 +513,16 @@ export class ChatCore {
|
|
|
495
513
|
const log = this.logs.get(chatId)!;
|
|
496
514
|
const events = since === undefined ? null : log.after(since);
|
|
497
515
|
if (events !== null) {
|
|
498
|
-
return { info: session.info, items: [], events, seq: log.seq };
|
|
516
|
+
return { info: session.info, items: [], events: [...events, ...session.uiPreviews], seq: log.seq };
|
|
499
517
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
518
|
+
const page = historyLimit === undefined ? session.thread.snapshot() : session.thread.history(historyLimit);
|
|
519
|
+
return {
|
|
520
|
+
...page,
|
|
521
|
+
info: session.info,
|
|
522
|
+
items: session.previewItems(page.items),
|
|
523
|
+
...(historyLimit === undefined ? {} : { pending: session.previewItems(session.thread.pending()) }),
|
|
524
|
+
seq: log.seq
|
|
525
|
+
};
|
|
503
526
|
}
|
|
504
527
|
|
|
505
528
|
/*
|
|
@@ -703,6 +726,34 @@ export class ChatCore {
|
|
|
703
726
|
return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
704
727
|
}
|
|
705
728
|
|
|
729
|
+
async linkUi(payload: ChatUiLinkPayload, clientId: string) {
|
|
730
|
+
if (!this.attached.get(payload.chatId)?.has(clientId) || !this.uiQueries) {
|
|
731
|
+
throw new ChatError('refused-query', 'Attach to the chat before resolving its UI links.');
|
|
732
|
+
}
|
|
733
|
+
return this.uiQueries.link(this.require(payload.chatId), payload);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async queryUi(payload: ChatUiQueryPayload, clientId: string) {
|
|
737
|
+
if (!this.attached.get(payload.chatId)?.has(clientId)) {
|
|
738
|
+
throw new ChatError('refused-query', 'Attach to the chat before reading its UI queries.');
|
|
739
|
+
}
|
|
740
|
+
if (!this.uiQueries) {
|
|
741
|
+
throw new ChatError('refused-query', 'This host does not provide UI query sources.');
|
|
742
|
+
}
|
|
743
|
+
return this.uiQueries.query(this.require(payload.chatId), payload);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async choose(payload: ChatUiChoicePayload, clientId: string): Promise<{ queued: boolean; turnId: string }> {
|
|
747
|
+
if (!this.attached.get(payload.chatId)?.has(clientId)) {
|
|
748
|
+
throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
|
|
749
|
+
}
|
|
750
|
+
const session = this.require(payload.chatId);
|
|
751
|
+
const queries = await this.uiQueries?.choiceValues(session, payload);
|
|
752
|
+
const result = session.choose(payload, queries);
|
|
753
|
+
await this.persisted(payload.chatId);
|
|
754
|
+
return result;
|
|
755
|
+
}
|
|
756
|
+
|
|
706
757
|
/* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
|
|
707
758
|
attachment(chatId: string, id: string): ChatAttachment | null {
|
|
708
759
|
const session = this.chats.get(chatId);
|
|
@@ -710,7 +761,8 @@ export class ChatCore {
|
|
|
710
761
|
return null;
|
|
711
762
|
}
|
|
712
763
|
for (const item of session.thread.list()) {
|
|
713
|
-
const
|
|
764
|
+
const generated = generatedImageAttachment(item);
|
|
765
|
+
const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : generated?.id === id ? generated : null;
|
|
714
766
|
if (found) {
|
|
715
767
|
return found;
|
|
716
768
|
}
|
|
@@ -759,7 +811,7 @@ export class ChatCore {
|
|
|
759
811
|
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
760
812
|
}
|
|
761
813
|
await this.clearing(chatId);
|
|
762
|
-
session.clear(force);
|
|
814
|
+
const ended = session.clear(force);
|
|
763
815
|
// Before the record is written, so what it takes along from the record is gone from the write as well.
|
|
764
816
|
const cleared = this.cleared(chatId);
|
|
765
817
|
// A debounced write still waiting holds the old thread and must not land after the empty one.
|
|
@@ -767,7 +819,7 @@ export class ChatCore {
|
|
|
767
819
|
// Folded right away: every line before the reset describes a thread that is gone.
|
|
768
820
|
await Promise.all([
|
|
769
821
|
this.persistNow(chatId, true),
|
|
770
|
-
this.attachments.removeAll(chatId),
|
|
822
|
+
ended.then(() => this.attachments.removeAll(chatId)),
|
|
771
823
|
this.bookmarks?.removeChat(chatId),
|
|
772
824
|
this.visuals?.removeChat(chatId),
|
|
773
825
|
cleared
|
|
@@ -812,8 +864,9 @@ export class ChatCore {
|
|
|
812
864
|
const session = this.require(chatId);
|
|
813
865
|
// A write already out would put the record back after the delete.
|
|
814
866
|
const writing = this.writes.get(chatId);
|
|
815
|
-
|
|
867
|
+
const ended = session.dispose();
|
|
816
868
|
this.subagents.releaseChat(chatId);
|
|
869
|
+
this.uiQueries?.forget(chatId);
|
|
817
870
|
this.coalescers.get(chatId)?.dispose();
|
|
818
871
|
this.coalescers.delete(chatId);
|
|
819
872
|
this.logs.get(chatId)?.close();
|
|
@@ -829,6 +882,7 @@ export class ChatCore {
|
|
|
829
882
|
const snapshot = session.thread.snapshot();
|
|
830
883
|
this.forgotten(chatId);
|
|
831
884
|
await writing;
|
|
885
|
+
await ended;
|
|
832
886
|
await Promise.all([
|
|
833
887
|
this.store?.delete(chatId),
|
|
834
888
|
this.attachments.removeAll(chatId),
|
|
@@ -979,7 +1033,15 @@ export class ChatCore {
|
|
|
979
1033
|
const log = this.logs.get(chatId);
|
|
980
1034
|
// Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
|
|
981
1035
|
try {
|
|
982
|
-
this.store.writeSync(
|
|
1036
|
+
this.store.writeSync(
|
|
1037
|
+
chatId,
|
|
1038
|
+
info,
|
|
1039
|
+
items,
|
|
1040
|
+
{ seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 },
|
|
1041
|
+
session.preambles,
|
|
1042
|
+
this.recordExtras(chatId),
|
|
1043
|
+
session.uiAccessRecord
|
|
1044
|
+
);
|
|
983
1045
|
} catch (e) {
|
|
984
1046
|
console.error(`Chat record for ${chatId} failed:`, errorText(e));
|
|
985
1047
|
}
|
|
@@ -1138,7 +1200,8 @@ export class ChatCore {
|
|
|
1138
1200
|
// A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
|
|
1139
1201
|
this.coalescers.get(chatId)?.flush();
|
|
1140
1202
|
const extras = this.recordExtras(chatId);
|
|
1141
|
-
const
|
|
1203
|
+
const uiAccess = session.uiAccessRecord;
|
|
1204
|
+
const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
|
|
1142
1205
|
const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
|
|
1143
1206
|
// The log already holds every change to the thread, so the whole record is rewritten only to fold
|
|
1144
1207
|
// the log, on a chat's first write, or for what no event carries.
|
|
@@ -1147,7 +1210,7 @@ export class ChatCore {
|
|
|
1147
1210
|
}
|
|
1148
1211
|
const { info, items } = session.thread.snapshot();
|
|
1149
1212
|
const at = { seq: log.seq, resetSeq: log.resetSeq };
|
|
1150
|
-
this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras));
|
|
1213
|
+
this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
|
|
1151
1214
|
this.unlogged.set(chatId, unlogged);
|
|
1152
1215
|
// A chat killed while the write was out has no log left to fold.
|
|
1153
1216
|
if (this.logs.get(chatId) === log && folds) {
|
|
@@ -1162,6 +1225,14 @@ export class ChatCore {
|
|
|
1162
1225
|
}
|
|
1163
1226
|
|
|
1164
1227
|
private emit(chatId: string, event: ChatEvent): void {
|
|
1228
|
+
if (event.type === 'delta' && event.ui !== undefined) {
|
|
1229
|
+
// Previews follow all pending text but have no log position or observer side effects.
|
|
1230
|
+
this.coalescers.get(chatId)?.flush();
|
|
1231
|
+
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
1232
|
+
this.sinks.to(clientId, { event: 'chat.event', payload: { chatId, event } });
|
|
1233
|
+
}
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1165
1236
|
this.activity.set(chatId, Date.now());
|
|
1166
1237
|
let coalescer = this.coalescers.get(chatId);
|
|
1167
1238
|
if (!coalescer) {
|