@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
@@ -0,0 +1,416 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { z } from 'zod';
3
+ import type {
4
+ ChatUiLinkPayload,
5
+ ChatUiLinkReading,
6
+ ChatAssistantItem,
7
+ ChatInfo,
8
+ ChatUiChoicePayload,
9
+ ChatUiQueryPayload,
10
+ ChatUiQueryReading
11
+ } from '@adecore/agent-contracts';
12
+ import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost, type UiValue } from '@adecore/intelligent-ui';
13
+ import { UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
14
+ import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
15
+ import type { UiBlock } from '@adecore/intelligent-ui/compiler';
16
+ import type { ChatSession } from './chat-session.ts';
17
+ import { ChatError } from './errors.ts';
18
+
19
+ export interface ChatUiSource {
20
+ args: z.ZodType;
21
+ result: z.ZodType;
22
+ authorize(info: ChatInfo, access: unknown, args: Record<string, UiValue>): Promise<void>;
23
+ read(info: ChatInfo, args: Record<string, UiValue>, signal: AbortSignal, access: unknown): Promise<unknown>;
24
+ }
25
+
26
+ export interface ChatUiHost {
27
+ capture(info: ChatInfo): Promise<unknown>;
28
+ sources: Readonly<Record<string, ChatUiSource>>;
29
+ link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
30
+ }
31
+
32
+ interface CachedReading {
33
+ key: string;
34
+ chatId: string;
35
+ reading: ChatUiQueryReading;
36
+ }
37
+
38
+ /*
39
+ * A failure a client can say in its own words: `reason` is a code that stays the same across versions,
40
+ * the message is the fallback for a client that does not know the code.
41
+ */
42
+ class UiQueryError extends ChatError<string> {
43
+ readonly reason: string;
44
+
45
+ constructor(code: string, reason: string, message: string) {
46
+ super(code, message);
47
+ this.reason = reason;
48
+ }
49
+ }
50
+
51
+ /*
52
+ * What a host's `authorize`, `read` or `link` throws to say why with a code: `code` reaches the reading's
53
+ * `code` and should stay the same across versions, `reason` is the plain sentence a client shows without it.
54
+ */
55
+ export class ChatUiRefusal extends UiQueryError {
56
+ constructor(code: string, reason: string) {
57
+ super('refused-query', code, reason);
58
+ }
59
+ }
60
+
61
+ const MAX_QUERY_BYTES = 64 * 1024;
62
+ const MAX_SNAPSHOT_BYTES = 128 * 1024;
63
+ const READ_TIMEOUT_MS = 8_000;
64
+ // A changed input reads at once, but a query never reads more often than this across its inputs.
65
+ const INPUT_READ_MS = 250;
66
+
67
+ export class ChatUiQueries {
68
+ readonly schemas: Record<string, z.ZodType>;
69
+ private readonly captures = new Map<string, Promise<unknown>>();
70
+ private readonly freezing = new Set<string>();
71
+ private readonly cache = new Map<string, CachedReading>();
72
+ // The read id of the newest reading per cache key, which a remount or a second client is answered with.
73
+ private readonly latest = new Map<string, string>();
74
+ private readonly reading = new Map<string, Promise<ChatUiQueryReading>>();
75
+ private readonly attempts = new Map<string, number>();
76
+ private readonly active = new Map<string, number>();
77
+ private totalActive = 0;
78
+
79
+ private readonly host: ChatUiHost;
80
+
81
+ constructor(host: ChatUiHost) {
82
+ this.host = host;
83
+ this.schemas = Object.fromEntries(Object.entries(host.sources).map(([name, source]) => [name, source.args]));
84
+ }
85
+
86
+ observe(session: ChatSession, item: ChatAssistantItem, final: boolean): void {
87
+ const key = `${session.id}:${item.id}`;
88
+ // Once a capture started, every delta of a streaming reply would scan its whole text again for nothing.
89
+ const captured = this.captures.has(key) || item.uiQueries?.authorChatId === session.id;
90
+ if (!captured && !uiMayReferenceHost(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
91
+ return;
92
+ }
93
+ const capture = this.access(session, item);
94
+ if (!final || !item.ui?.length) {
95
+ return;
96
+ }
97
+ if (this.freezing.has(key)) {
98
+ return;
99
+ }
100
+ this.freezing.add(key);
101
+ void capture
102
+ .then(async () => {
103
+ for (const block of (item.ui ?? []).slice(0, 16)) {
104
+ const current = session.thread.get(item.id);
105
+ if (current?.kind !== 'assistant' || current.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
106
+ return;
107
+ }
108
+ if (current.uiQueries?.blocks[block.id]?.revision === block.revision) {
109
+ continue;
110
+ }
111
+ const readings: Record<string, ChatUiQueryReading> = Object.create(null);
112
+ const values: Record<string, unknown> = Object.create(null);
113
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
114
+ const reading = await this.query(session, {
115
+ chatId: session.id,
116
+ itemId: item.id,
117
+ blockId: block.id,
118
+ revision: block.revision!,
119
+ query: name
120
+ });
121
+ readings[name] = reading;
122
+ if (reading.state === 'fresh') {
123
+ values[name] = reading.value;
124
+ }
125
+ }
126
+ const links: Record<string, ChatUiLinkReading> = Object.create(null);
127
+ if (this.host.link) {
128
+ const targets = uiLinkTargets(block, {}, values);
129
+ const access = await this.access(session, current);
130
+ for (const [id, target] of Object.entries(targets).slice(0, UI_HOST_LIMITS.links)) {
131
+ try {
132
+ links[id] = this.linkReading(await this.host.link(session.info, access, target));
133
+ } catch (error) {
134
+ links[id] = { state: 'plain', ...this.failure(error) };
135
+ }
136
+ }
137
+ }
138
+ const latest = session.thread.get(item.id);
139
+ if (latest?.kind !== 'assistant' || !latest.uiQueries || latest.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
140
+ return;
141
+ }
142
+ const names = Object.entries(block.queries)
143
+ .map(([name, query]) => `${name}: ${query.source}`)
144
+ .join(', ');
145
+ const failures = Object.entries(readings)
146
+ .filter(([, reading]) => reading.state !== 'fresh')
147
+ .map(([name, reading]) => `${name}: ${reading.reason}`)
148
+ .join('\n');
149
+ const frozen = {
150
+ revision: block.revision!,
151
+ readings,
152
+ links,
153
+ fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}`
154
+ };
155
+ const blocks = { ...latest.uiQueries.blocks, [block.id]: frozen };
156
+ if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
157
+ frozen.readings = Object.fromEntries(
158
+ Object.keys(readings).map((name) => [
159
+ name,
160
+ { state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
161
+ ])
162
+ );
163
+ frozen.links = Object.fromEntries(
164
+ Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }])
165
+ );
166
+ frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
167
+ }
168
+ if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
169
+ delete blocks[block.id];
170
+ }
171
+ session.updateUiQueries(item.id, { ...latest.uiQueries, blocks });
172
+ }
173
+ })
174
+ .catch(() => {})
175
+ .finally(() => this.freezing.delete(key));
176
+ }
177
+
178
+ /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
179
+ private access(session: ChatSession, item: ChatAssistantItem): Promise<unknown> {
180
+ const key = `${session.id}:${item.id}`;
181
+ const existing = this.captures.get(key);
182
+ if (existing) {
183
+ return existing;
184
+ }
185
+ if (item.uiQueries?.authorChatId === session.id) {
186
+ return Promise.resolve(session.uiAccess(item.id) ?? null);
187
+ }
188
+ // A pending capture is persisted as denied, so a restart cannot widen the writer's rights.
189
+ session.setUiAccess(item.id, null);
190
+ session.updateUiQueries(item.id, { authorChatId: session.id, blocks: {} });
191
+ const captured = this.host
192
+ .capture(session.info)
193
+ .then((access) => {
194
+ const captured = copyUiValue(access, new UiBudget());
195
+ if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
196
+ throw new Error('The UI access snapshot is too large.');
197
+ }
198
+ session.setUiAccess(item.id, captured);
199
+ return captured as unknown;
200
+ })
201
+ .catch(() => null)
202
+ .finally(() => this.captures.delete(key));
203
+ this.captures.set(key, captured);
204
+ return captured;
205
+ }
206
+
207
+ private storedBlock(
208
+ session: ChatSession,
209
+ payload: Pick<ChatUiQueryPayload, 'itemId' | 'blockId' | 'revision'>
210
+ ): { item: ChatAssistantItem; block: UiBlock } {
211
+ const item = session.thread.get(payload.itemId);
212
+ const block =
213
+ item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
214
+ if (item?.kind !== 'assistant' || !block?.complete || block.revision !== payload.revision) {
215
+ throw new UiQueryError('stale-ui-block', 'block-stale', 'This completed UI block is no longer available.');
216
+ }
217
+ return { item, block };
218
+ }
219
+
220
+ private stored(session: ChatSession, payload: ChatUiQueryPayload): { item: ChatAssistantItem; block: UiBlock; source: ChatUiSource } {
221
+ const { item, block } = this.storedBlock(session, payload);
222
+ if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > UI_HOST_LIMITS.queries) {
223
+ throw new UiQueryError('refused-query', 'query-undeclared', 'This block does not declare an allowed query.');
224
+ }
225
+ const name = block.queries[payload.query].source;
226
+ if (!Object.hasOwn(this.host.sources, name)) {
227
+ throw new UiQueryError('refused-query', 'source-unregistered', 'This query source is not registered.');
228
+ }
229
+ return { item, block, source: this.host.sources[name] };
230
+ }
231
+
232
+ async query(session: ChatSession, payload: ChatUiQueryPayload): Promise<ChatUiQueryReading> {
233
+ let args: Record<string, UiValue>;
234
+ let source: ChatUiSource;
235
+ let block: UiBlock;
236
+ let item: ChatAssistantItem;
237
+ let authorAccess: unknown;
238
+ try {
239
+ ({ source, block, item } = this.stored(session, payload));
240
+ args = uiQueryArguments(block, payload.query, payload.values);
241
+ args = source.args.parse(args) as Record<string, UiValue>;
242
+ authorAccess = await this.access(session, item);
243
+ if (authorAccess === null) {
244
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
245
+ }
246
+ await source.authorize(session.info, authorAccess, args);
247
+ } catch (error) {
248
+ return { state: 'refused', readAt: Date.now(), ...this.failure(error) };
249
+ }
250
+ const key = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query, args]);
251
+ const cached = this.cache.get(this.latest.get(key) ?? '');
252
+ if (cached && Date.now() - cached.reading.readAt < UI_HOST_LIMITS.refreshMilliseconds) {
253
+ return cached.reading;
254
+ }
255
+ const pending = this.reading.get(key);
256
+ if (pending) {
257
+ return pending;
258
+ }
259
+ const identity = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query]);
260
+ const now = Date.now();
261
+ if (
262
+ now - (this.attempts.get(key) ?? -Infinity) < UI_HOST_LIMITS.refreshMilliseconds ||
263
+ now - (this.attempts.get(identity) ?? -Infinity) < INPUT_READ_MS
264
+ ) {
265
+ return { state: 'failed', readAt: now, code: 'refresh-limit', reason: 'This query may refresh once every ten seconds.' };
266
+ }
267
+ if ((this.active.get(session.id) ?? 0) >= 2 || this.totalActive >= 16) {
268
+ return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
269
+ }
270
+ this.attempts.set(key, now);
271
+ this.attempts.set(identity, now);
272
+ while (this.attempts.size > 256) {
273
+ this.attempts.delete(this.attempts.keys().next().value!);
274
+ }
275
+ this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
276
+ this.totalActive++;
277
+ const controller = new AbortController();
278
+ const work = Promise.resolve().then(() => source.read(session.info, args, controller.signal, authorAccess));
279
+ const timeout = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
280
+ timeout.unref?.();
281
+ const read = Promise.race([
282
+ work,
283
+ new Promise<never>((_, reject) =>
284
+ controller.signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The UI query timed out.')), {
285
+ once: true
286
+ })
287
+ )
288
+ ])
289
+ .then((result): ChatUiQueryReading => {
290
+ const value = copyUiValue(source.result.parse(result), new UiBudget());
291
+ if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
292
+ throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
293
+ }
294
+ const reading: ChatUiQueryReading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
295
+ this.cache.set(reading.readId!, { key, chatId: session.id, reading });
296
+ this.latest.set(key, reading.readId!);
297
+ while (this.cache.size > 128) {
298
+ this.evict(this.cache.keys().next().value!);
299
+ }
300
+ return reading;
301
+ })
302
+ .catch((error): ChatUiQueryReading => ({ state: 'failed', readAt: Date.now(), ...this.failure(error) }))
303
+ .finally(() => {
304
+ clearTimeout(timeout);
305
+ this.reading.delete(key);
306
+ });
307
+ // Timed-out sources still occupy their slot until their actual work ends.
308
+ void work
309
+ .catch(() => {})
310
+ .finally(() => {
311
+ const count = (this.active.get(session.id) ?? 1) - 1;
312
+ if (count) {
313
+ this.active.set(session.id, count);
314
+ } else {
315
+ this.active.delete(session.id);
316
+ }
317
+ this.totalActive--;
318
+ });
319
+ this.reading.set(key, read);
320
+ return read;
321
+ }
322
+
323
+ /* The issued value of every query the block declares, each checked against the read id the person saw. */
324
+ async choiceValues(session: ChatSession, payload: ChatUiChoicePayload | ChatUiLinkPayload): Promise<Record<string, unknown>> {
325
+ const item = session.thread.get(payload.itemId);
326
+ const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
327
+ if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
328
+ return {};
329
+ }
330
+ const names = Object.keys(block.queries);
331
+ const values: Record<string, unknown> = Object.create(null);
332
+ if (names.length === 0) {
333
+ return values;
334
+ }
335
+ const access = await this.access(session, item);
336
+ if (access === null) {
337
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
338
+ }
339
+ for (const name of names) {
340
+ const { source } = this.stored(session, { ...payload, query: name });
341
+ const args = source.args.parse(uiQueryArguments(block, name, payload.values)) as Record<string, UiValue>;
342
+ await source.authorize(session.info, access, args);
343
+ const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
344
+ const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
345
+ const cached = readId === undefined ? undefined : this.cache.get(readId);
346
+ const frozen = item.uiQueries?.blocks[block.id];
347
+ const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
348
+ const reading =
349
+ readId === undefined
350
+ ? undefined
351
+ : cached?.key === key
352
+ ? cached.reading
353
+ : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
354
+ ? snapshot
355
+ : undefined;
356
+ if (!reading || reading.state !== 'fresh') {
357
+ throw new UiQueryError('stale-ui-query', 'stale-read', 'Refresh this query before sending its choice.');
358
+ }
359
+ values[name] = reading.value;
360
+ }
361
+ return values;
362
+ }
363
+
364
+ async link(session: ChatSession, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading> {
365
+ try {
366
+ if (!this.host.link) {
367
+ throw new UiQueryError('refused-query', 'links-unsupported', 'This host does not provide UI links.');
368
+ }
369
+ const { item, block } = this.storedBlock(session, payload);
370
+ if (!item.uiQueries) {
371
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
372
+ }
373
+ const access = await this.access(session, item);
374
+ if (access === null) {
375
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
376
+ }
377
+ const queries = await this.choiceValues(session, payload);
378
+ const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
379
+ if (!target) {
380
+ throw new UiQueryError('refused-query', 'link-unsupported', 'This visible node is not a supported link.');
381
+ }
382
+ return this.linkReading(await this.host.link(session.info, access, target));
383
+ } catch (error) {
384
+ return { state: 'plain', ...this.failure(error) };
385
+ }
386
+ }
387
+
388
+ forget(chatId: string): void {
389
+ for (const [id, cached] of this.cache) {
390
+ if (cached.chatId === chatId) {
391
+ this.evict(id);
392
+ }
393
+ }
394
+ }
395
+
396
+ private evict(readId: string): void {
397
+ const cached = this.cache.get(readId);
398
+ this.cache.delete(readId);
399
+ if (cached && this.latest.get(cached.key) === readId) {
400
+ this.latest.delete(cached.key);
401
+ }
402
+ }
403
+
404
+ private linkReading(value: unknown): ChatUiLinkReading {
405
+ const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
406
+ if (reading.state === 'chip' && !reading.target) {
407
+ throw new Error('The host did not resolve a link target.');
408
+ }
409
+ return reading;
410
+ }
411
+
412
+ private failure(error: unknown): { reason: string; code?: string } {
413
+ const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
414
+ return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
415
+ }
416
+ }
@@ -66,6 +66,9 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
66
66
  { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats },
67
67
  payload.attachments
68
68
  ),
69
+ 'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
70
+ 'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
71
+ 'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
69
72
  'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
70
73
  'chat.sendNow': (payload) => {
71
74
  chats.sendNow(payload.chatId, payload.messageId);
@@ -1,6 +1,7 @@
1
1
  import type { ChatBackgroundTask, ChatItem, ChatTurnItem, Task, TaskResult } from '@adecore/agent-contracts';
2
2
  import { abortedByMachine, clipText } from '@adecore/agent-contracts';
3
3
  import { BACKGROUND_COMMAND_LIMIT_MS, commandLabel, isBackgroundWork, runningInBackground, runsInBackground } from '../chat/background-work.ts';
4
+ import { readableAssistantText } from '../chat/readable-text.ts';
4
5
  import { errorText } from '../error-text.ts';
5
6
  import type { AgentEvent } from '../events.ts';
6
7
  import type { TaskStore } from './task-store.ts';
@@ -40,8 +41,11 @@ export interface TaskCoordinatorDeps {
40
41
  function answerOf(items: readonly ChatItem[], turnId: string): string | null {
41
42
  for (let i = items.length - 1; i >= 0; i--) {
42
43
  const item = items[i]!;
43
- if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId && item.text.trim() !== '') {
44
- return item.text.trim();
44
+ if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId) {
45
+ const text = readableAssistantText(item).trim();
46
+ if (text !== '') {
47
+ return text;
48
+ }
45
49
  }
46
50
  }
47
51
  return null;