@copilotkit/channels-core 0.4.1-canary.1785375021 → 0.4.1-canary.narrowinstall1

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/thread.js CHANGED
@@ -2,14 +2,6 @@ import { runAgentLoop } from "./run-loop.js";
2
2
  import { errorClass, normalizePlatform } from "./telemetry/sanitize-error.js";
3
3
  import { toAgentToolDescriptors } from "./tools.js";
4
4
  import { validateSchema } from "./standard-schema.js";
5
- /** Stable rejection for surfaces that cannot hold one run open for a choice. */
6
- class ChannelAwaitChoiceNotSupportedError extends Error {
7
- code = "channel_await_choice_not_supported";
8
- constructor() {
9
- super("Managed Channels v1 does not support Thread.awaitChoice(); post the picker in an onInterrupt handler and call Thread.resume() from the later interaction delivery.");
10
- this.name = "ChannelAwaitChoiceNotSupportedError";
11
- }
12
- }
13
5
  /** A concrete conversation thread: posts UI, runs the agent loop, and resolves HITL waiters. */
14
6
  export class Thread {
15
7
  deps;
@@ -19,10 +11,9 @@ export class Thread {
19
11
  /** Mirrors the adapter's `supportsBlockingChoice` capability (see SurfaceCapabilities). */
20
12
  supportsBlockingChoice;
21
13
  store;
22
- implicitInboundConsumed = false;
23
14
  constructor(deps) {
24
15
  this.deps = deps;
25
- this.platform = deps.platform ?? deps.adapter.platform;
16
+ this.platform = deps.adapter.platform;
26
17
  this.conversationKey = deps.conversationKey;
27
18
  this.supportsBlockingChoice =
28
19
  deps.adapter.capabilities.supportsBlockingChoice;
@@ -31,17 +22,6 @@ export class Thread {
31
22
  async bindForPost(ui) {
32
23
  return this.deps.registry.bindRenderable(ui, this.deps.conversationKey);
33
24
  }
34
- trackOperation(operation) {
35
- if (!this.deps.adapter.trackThreadOperation) {
36
- try {
37
- return operation();
38
- }
39
- catch (error) {
40
- return Promise.reject(error);
41
- }
42
- }
43
- return this.deps.adapter.trackThreadOperation(this.deps.replyTarget, operation);
44
- }
45
25
  /**
46
26
  * Wire a posted message's `onReaction` to its returned id: cache it for this
47
27
  * process and, when it came from a component, persist a durable snapshot so a
@@ -58,355 +38,299 @@ export class Thread {
58
38
  });
59
39
  }
60
40
  }
61
- post(ui) {
62
- return this.trackOperation(async () => {
63
- const bound = await this.bindForPost(ui);
64
- const ref = await this.deps.adapter.post(this.deps.replyTarget, bound.root);
65
- await this.bindReaction(ref.id, bound);
66
- return ref;
67
- });
41
+ async post(ui) {
42
+ const bound = await this.bindForPost(ui);
43
+ const ref = await this.deps.adapter.post(this.deps.replyTarget, bound.root);
44
+ await this.bindReaction(ref.id, bound);
45
+ return ref;
68
46
  }
69
- update(ref, ui) {
70
- return this.trackOperation(async () => {
71
- const bound = await this.bindForPost(ui);
72
- await this.deps.adapter.update(ref, bound.root);
73
- await this.bindReaction(ref.id, bound);
74
- return ref;
75
- });
47
+ async update(ref, ui) {
48
+ const bound = await this.bindForPost(ui);
49
+ await this.deps.adapter.update(ref, bound.root);
50
+ await this.bindReaction(ref.id, bound);
51
+ return ref;
76
52
  }
77
- delete(ref) {
78
- return this.trackOperation(() => this.deps.adapter.delete(ref));
53
+ async delete(ref) {
54
+ await this.deps.adapter.delete(ref);
79
55
  }
80
- stream(src) {
81
- return this.trackOperation(() => {
82
- const iter = typeof src === "string"
83
- ? (async function* () {
84
- yield src;
85
- })()
86
- : src;
87
- return this.deps.adapter.stream(this.deps.replyTarget, iter);
88
- });
56
+ async stream(src) {
57
+ const iter = typeof src === "string"
58
+ ? (async function* () {
59
+ yield src;
60
+ })()
61
+ : src;
62
+ return this.deps.adapter.stream(this.deps.replyTarget, iter);
89
63
  }
90
- postFile(args) {
91
- return this.trackOperation(async () => {
92
- const adapter = this.deps.adapter;
93
- if (!adapter.postFile) {
94
- return {
95
- ok: false,
96
- error: `${this.platform} does not support file upload`,
97
- };
98
- }
99
- return adapter.postFile(this.deps.replyTarget, args);
100
- });
64
+ async postFile(args) {
65
+ const adapter = this.deps.adapter;
66
+ if (!adapter.postFile) {
67
+ return {
68
+ ok: false,
69
+ error: `${this.platform} does not support file upload`,
70
+ };
71
+ }
72
+ return adapter.postFile(this.deps.replyTarget, args);
101
73
  }
102
74
  /** Pin suggested prompts (returns `{ ok: false }` on surfaces without support). */
103
- setSuggestedPrompts(prompts, opts) {
104
- return this.trackOperation(async () => {
105
- const adapter = this.deps.adapter;
106
- if (!adapter.setSuggestedPrompts) {
107
- return {
108
- ok: false,
109
- error: `${this.platform} does not support suggested prompts`,
110
- };
111
- }
112
- return adapter.setSuggestedPrompts(this.deps.replyTarget, prompts, opts);
113
- });
75
+ async setSuggestedPrompts(prompts, opts) {
76
+ const adapter = this.deps.adapter;
77
+ if (!adapter.setSuggestedPrompts) {
78
+ return {
79
+ ok: false,
80
+ error: `${this.platform} does not support suggested prompts`,
81
+ };
82
+ }
83
+ return adapter.setSuggestedPrompts(this.deps.replyTarget, prompts, opts);
114
84
  }
115
85
  /** Name this conversation (returns `{ ok: false }` on surfaces without support). */
116
- setTitle(title) {
117
- return this.trackOperation(async () => {
118
- const adapter = this.deps.adapter;
119
- if (!adapter.setThreadTitle) {
120
- return {
121
- ok: false,
122
- error: `${this.platform} does not support thread titles`,
123
- };
124
- }
125
- return adapter.setThreadTitle(this.deps.replyTarget, title);
126
- });
86
+ async setTitle(title) {
87
+ const adapter = this.deps.adapter;
88
+ if (!adapter.setThreadTitle) {
89
+ return {
90
+ ok: false,
91
+ error: `${this.platform} does not support thread titles`,
92
+ };
93
+ }
94
+ return adapter.setThreadTitle(this.deps.replyTarget, title);
127
95
  }
128
96
  /** Add an emoji reaction to a message (capability-gated; `{ ok: false }` on surfaces without support). */
129
- react(messageRef, emoji) {
130
- return this.trackOperation(async () => {
131
- const adapter = this.deps.adapter;
132
- if (!adapter.addReaction) {
133
- return {
134
- ok: false,
135
- error: `${this.platform} does not support reactions`,
136
- };
137
- }
138
- return adapter.addReaction(this.deps.replyTarget, messageRef, emoji);
139
- });
97
+ async react(messageRef, emoji) {
98
+ const adapter = this.deps.adapter;
99
+ if (!adapter.addReaction) {
100
+ return {
101
+ ok: false,
102
+ error: `${this.platform} does not support reactions`,
103
+ };
104
+ }
105
+ return adapter.addReaction(this.deps.replyTarget, messageRef, emoji);
140
106
  }
141
107
  /** Remove the channel's emoji reaction from a message (capability-gated). */
142
- unreact(messageRef, emoji) {
143
- return this.trackOperation(async () => {
144
- const adapter = this.deps.adapter;
145
- if (!adapter.removeReaction) {
146
- return {
147
- ok: false,
148
- error: `${this.platform} does not support reactions`,
149
- };
150
- }
151
- return adapter.removeReaction(this.deps.replyTarget, messageRef, emoji);
152
- });
108
+ async unreact(messageRef, emoji) {
109
+ const adapter = this.deps.adapter;
110
+ if (!adapter.removeReaction) {
111
+ return {
112
+ ok: false,
113
+ error: `${this.platform} does not support reactions`,
114
+ };
115
+ }
116
+ return adapter.removeReaction(this.deps.replyTarget, messageRef, emoji);
153
117
  }
154
118
  /**
155
119
  * Post a message only `user` can see. `fallbackToDM` is required:
156
120
  * `true` → DM the user when native ephemeral is unsupported; `false` →
157
121
  * resolve to `null` when native ephemeral is unsupported.
158
122
  */
159
- postEphemeral(user, ui, opts) {
160
- return this.trackOperation(async () => {
161
- const adapter = this.deps.adapter;
162
- if (!adapter.postEphemeral) {
163
- return {
164
- ok: false,
165
- error: `${this.platform} does not support ephemeral messages`,
166
- };
167
- }
168
- // Ephemeral messages can't be reacted to, so any `onReaction` is dropped
169
- // (stripped by bindForPost) rather than registered.
170
- const { root } = await this.bindForPost(ui);
171
- return adapter.postEphemeral(this.deps.replyTarget, user, root, opts);
172
- });
123
+ async postEphemeral(user, ui, opts) {
124
+ const adapter = this.deps.adapter;
125
+ if (!adapter.postEphemeral) {
126
+ return {
127
+ ok: false,
128
+ error: `${this.platform} does not support ephemeral messages`,
129
+ };
130
+ }
131
+ // Ephemeral messages can't be reacted to, so any `onReaction` is dropped
132
+ // (stripped by bindForPost) rather than registered.
133
+ const { root } = await this.bindForPost(ui);
134
+ return adapter.postEphemeral(this.deps.replyTarget, user, root, opts);
173
135
  }
174
136
  // Subscription STORAGE lands here; subscription ROUTING (onSubscribedMessage) is deferred.
175
137
  /** Record this conversation as subscribed (persisted in state). Proactive delivery to subscribed conversations is not yet wired. */
176
- subscribe() {
177
- return this.trackOperation(() => this.store.kv.set(`sub:${this.deps.conversationKey}`, true));
138
+ async subscribe() {
139
+ await this.store.kv.set(`sub:${this.deps.conversationKey}`, true);
178
140
  }
179
141
  /** Remove the subscription for this conversation. */
180
- unsubscribe() {
181
- return this.trackOperation(() => this.store.kv.delete(`sub:${this.deps.conversationKey}`));
142
+ async unsubscribe() {
143
+ await this.store.kv.delete(`sub:${this.deps.conversationKey}`);
182
144
  }
183
145
  /** Returns true if this conversation is currently subscribed. */
184
- isSubscribed() {
185
- return this.trackOperation(async () => (await this.store.kv.get(`sub:${this.deps.conversationKey}`)) === true);
146
+ async isSubscribed() {
147
+ return ((await this.store.kv.get(`sub:${this.deps.conversationKey}`)) ===
148
+ true);
186
149
  }
187
150
  /** Persist arbitrary per-thread state (e.g. workflow step). */
188
- setState(v) {
189
- return this.trackOperation(async () => {
190
- let value = v;
191
- if (this.deps.stateSchema) {
192
- const r = await validateSchema(this.deps.stateSchema, v);
193
- if (!r.ok)
194
- throw new Error(`thread.setState: invalid state — ${r.error}`);
195
- value = r.value;
196
- }
197
- await this.store.kv.set(`threadstate:${this.deps.conversationKey}`, value);
198
- });
151
+ async setState(v) {
152
+ let value = v;
153
+ if (this.deps.stateSchema) {
154
+ const r = await validateSchema(this.deps.stateSchema, v);
155
+ if (!r.ok)
156
+ throw new Error(`thread.setState: invalid state — ${r.error}`);
157
+ value = r.value;
158
+ }
159
+ await this.store.kv.set(`threadstate:${this.deps.conversationKey}`, value);
199
160
  }
200
161
  /** Read back per-thread state previously written with `setState`. */
201
- state() {
202
- return this.trackOperation(() => this.store.kv.get(`threadstate:${this.deps.conversationKey}`));
162
+ async state() {
163
+ return this.store.kv.get(`threadstate:${this.deps.conversationKey}`);
203
164
  }
204
165
  /** Read the conversation's messages (returns `[]` when the adapter can't read history). */
205
- getMessages() {
206
- return this.trackOperation(async () => (await this.deps.adapter.getMessages?.(this.deps.replyTarget)) ?? []);
166
+ async getMessages() {
167
+ return (await this.deps.adapter.getMessages?.(this.deps.replyTarget)) ?? [];
207
168
  }
208
169
  /** Resolve a platform user by free-form query (returns `undefined` when unsupported). */
209
- lookupUser(query) {
210
- return this.trackOperation(() => this.deps.adapter.lookupUser({ query }));
170
+ async lookupUser(query) {
171
+ return this.deps.adapter.lookupUser?.({ query });
211
172
  }
212
173
  /** Post a picker and wait until an interaction in this conversation resolves it. */
213
- awaitChoice(ui) {
214
- if (this.supportsBlockingChoice === false) {
215
- return Promise.reject(new ChannelAwaitChoiceNotSupportedError());
216
- }
217
- return this.trackOperation(async () => {
218
- const p = new Promise((resolve) => this.deps.registerWaiter(this.deps.conversationKey, resolve));
219
- await this.post(ui);
220
- return p;
221
- });
174
+ async awaitChoice(ui) {
175
+ const p = new Promise((resolve) => this.deps.registerWaiter(this.deps.conversationKey, resolve));
176
+ await this.post(ui);
177
+ return p;
222
178
  }
223
- runAgent(input) {
224
- try {
225
- this.deps.adapter.assertRunAgentSupported?.(this.deps.replyTarget);
226
- }
227
- catch (error) {
228
- return Promise.reject(error);
229
- }
230
- return this.trackOperation(async () => {
231
- const message = this.deps.message;
232
- const defaultPrompt = message?.contentParts && message.contentParts.length > 0
233
- ? message.contentParts
234
- : message?.text;
235
- const implicitPrompt = input?.prompt === undefined &&
236
- !this.deps.adapter.conversationStore.seedsInboundTurn &&
237
- (!this.deps.adapter.injectInboundTurnOnce ||
238
- !this.implicitInboundConsumed);
239
- if (implicitPrompt && defaultPrompt) {
240
- this.implicitInboundConsumed = true;
241
- }
242
- return this.run(undefined, {
243
- ...input,
244
- prompt: input?.prompt ?? (!implicitPrompt ? undefined : defaultPrompt),
245
- });
179
+ async runAgent(input) {
180
+ const message = this.deps.message;
181
+ const defaultPrompt = message?.contentParts && message.contentParts.length > 0
182
+ ? message.contentParts
183
+ : message?.text;
184
+ return this.run(undefined, {
185
+ ...input,
186
+ prompt: input?.prompt ??
187
+ (this.deps.adapter.conversationStore.seedsInboundTurn
188
+ ? undefined
189
+ : defaultPrompt),
246
190
  });
247
191
  }
248
- resume(value) {
249
- return this.trackOperation(() => this.run({ resume: value }));
192
+ async resume(value) {
193
+ return this.run({ resume: value });
250
194
  }
251
195
  async run(initialResume, extra) {
252
196
  const session = await this.deps.adapter.conversationStore.getOrCreate(this.deps.conversationKey, this.deps.replyTarget, this.deps.agentFactory);
253
- try {
254
- // Inject an explicit user message when the input isn't in the adapter's
255
- // reconstructed history (e.g. a slash command's args, or inbound image/file
256
- // attachments built into multimodal content parts). A non-empty array is
257
- // truthy, so this guard also admits multimodal prompts.
258
- if (extra?.prompt) {
259
- session.agent.addMessage({
260
- id: globalThis.crypto.randomUUID(),
261
- role: "user",
262
- // AG-UI types `content` as `string`, but multimodal works at runtime by
263
- // setting it to an `AgentContentPart[]` — the runtime's LLM adapter
264
- // converts the parts to the provider's multimodal format. We cast to
265
- // satisfy the string-typed field (channels-slack parity — it does the same
266
- // when assigning multimodal `content` to its reconstructed messages).
267
- content: extra.prompt,
268
- });
269
- }
270
- const renderer = this.deps.adapter.createRunRenderer(this.deps.replyTarget);
271
- // Transcript auto-bridge (step 1 + 2): inject prior cross-platform history
272
- // as a context entry, then append the current user turn. This flag owns the
273
- // bridge — see `runAgent`'s `transcript` doc. No-ops with one warning when
274
- // identity/transcripts aren't configured.
275
- const transcripts = this.deps.transcripts;
276
- const userKey = this.deps.userKey;
277
- let transcriptContext;
278
- if (extra?.transcript) {
279
- if (transcripts && userKey) {
280
- const limit = typeof extra.transcript === "object"
281
- ? (extra.transcript.limit ?? 20)
282
- : 20;
283
- // List BEFORE appending the current user turn so the current message
284
- // isn't counted as its own "prior history".
285
- const prior = await transcripts.list({ userKey, limit });
286
- if (prior.length > 0) {
287
- transcriptContext = {
288
- description: `Prior cross-platform conversation history with this user. Current channel: ${this.platform}.`,
289
- value: prior
290
- .map((e) => `[${e.platform}] ${e.role}: ${e.text}`)
291
- .join("\n"),
292
- };
293
- }
294
- if (this.deps.message) {
295
- await transcripts.append(this, this.deps.message, { userKey });
296
- }
197
+ // Inject an explicit user message when the input isn't in the adapter's
198
+ // reconstructed history (e.g. a slash command's args, or inbound image/file
199
+ // attachments built into multimodal content parts). A non-empty array is
200
+ // truthy, so this guard also admits multimodal prompts.
201
+ if (extra?.prompt) {
202
+ session.agent.addMessage({
203
+ id: globalThis.crypto.randomUUID(),
204
+ role: "user",
205
+ // AG-UI types `content` as `string`, but multimodal works at runtime by
206
+ // setting it to an `AgentContentPart[]` — the runtime's LLM adapter
207
+ // converts the parts to the provider's multimodal format. We cast to
208
+ // satisfy the string-typed field (channels-slack parity — it does the same
209
+ // when assigning multimodal `content` to its reconstructed messages).
210
+ content: extra.prompt,
211
+ });
212
+ }
213
+ const renderer = this.deps.adapter.createRunRenderer(this.deps.replyTarget);
214
+ // Transcript auto-bridge (step 1 + 2): inject prior cross-platform history
215
+ // as a context entry, then append the current user turn. This flag owns the
216
+ // bridge — see `runAgent`'s `transcript` doc. No-ops with one warning when
217
+ // identity/transcripts aren't configured.
218
+ const transcripts = this.deps.transcripts;
219
+ const userKey = this.deps.userKey;
220
+ let transcriptContext;
221
+ if (extra?.transcript) {
222
+ if (transcripts && userKey) {
223
+ const limit = typeof extra.transcript === "object"
224
+ ? (extra.transcript.limit ?? 20)
225
+ : 20;
226
+ // List BEFORE appending the current user turn so the current message
227
+ // isn't counted as its own "prior history".
228
+ const prior = await transcripts.list({ userKey, limit });
229
+ if (prior.length > 0) {
230
+ transcriptContext = {
231
+ description: `Prior cross-platform conversation history with this user. Current channel: ${this.platform}.`,
232
+ value: prior
233
+ .map((e) => `[${e.platform}] ${e.role}: ${e.text}`)
234
+ .join("\n"),
235
+ };
297
236
  }
298
- else {
299
- warnTranscriptIgnored();
237
+ if (this.deps.message) {
238
+ await transcripts.append(this, this.deps.message, { userKey });
300
239
  }
301
240
  }
302
- // Merge per-run context/tools (this run only) on top of the channel-level deps.
303
- const extraTools = extra?.tools ?? [];
304
- let tools = this.deps.tools;
305
- let toolDescriptors = this.deps.toolDescriptors;
306
- if (extraTools.length > 0) {
307
- tools = new Map(this.deps.tools);
308
- for (const t of extraTools)
309
- tools.set(t.name, t);
310
- toolDescriptors = [
311
- ...this.deps.toolDescriptors,
312
- ...toAgentToolDescriptors(extraTools),
313
- ];
241
+ else {
242
+ warnTranscriptIgnored();
314
243
  }
315
- const context = [
316
- ...this.deps.context,
317
- ...(transcriptContext ? [transcriptContext] : []),
318
- ...(extra?.context ?? []),
244
+ }
245
+ // Merge per-run context/tools (this run only) on top of the channel-level deps.
246
+ const extraTools = extra?.tools ?? [];
247
+ let tools = this.deps.tools;
248
+ let toolDescriptors = this.deps.toolDescriptors;
249
+ if (extraTools.length > 0) {
250
+ tools = new Map(this.deps.tools);
251
+ for (const t of extraTools)
252
+ tools.set(t.name, t);
253
+ toolDescriptors = [
254
+ ...this.deps.toolDescriptors,
255
+ ...toAgentToolDescriptors(extraTools),
319
256
  ];
320
- // Snapshot the message count BEFORE the loop so we can isolate the
321
- // assistant messages this run produced (step 4).
322
- const messagesBefore = session.agent.messages.length;
323
- const startedAt = Date.now();
324
- let loopResult;
325
- // Telemetry stage: "agent" while the run loop runs, "finalize" for the
326
- // transcript-append + renderer.finish() steps below. A throw in either is
327
- // reported as agent_run_failed (with the right stage) instead of being
328
- // hidden behind an already-sent success event.
329
- let stage = "agent";
330
- try {
331
- const loopArgs = {
332
- agent: session.agent,
333
- renderer,
334
- tools,
335
- toolDescriptors,
336
- context,
337
- makeToolCtx: () => ({
338
- thread: this,
339
- platform: this.platform,
340
- }),
341
- handleInterrupt: async (interrupt) => {
342
- const h = this.deps.interruptHandlers.get(interrupt.eventName);
343
- if (h)
344
- await h({ payload: interrupt.value, thread: this });
345
- },
346
- initialResume,
347
- };
348
- loopResult = this.deps.adapter.runAgentLifecycle
349
- ? await this.deps.adapter.runAgentLifecycle({
350
- replyTarget: this.deps.replyTarget,
351
- agent: session.agent,
352
- renderer,
353
- tools: toolDescriptors,
354
- context,
355
- isResume: initialResume !== undefined,
356
- execute: (subscriber, canonicalRun) => runAgentLoop({
357
- ...loopArgs,
358
- subscriber,
359
- ...(canonicalRun ? { canonicalRun } : {}),
360
- }),
361
- })
362
- : await runAgentLoop(loopArgs);
363
- stage = "finalize";
364
- // Transcript auto-bridge (step 4): capture the assistant text this run
365
- // produced and append it. Only when the bridge actually applied (transcripts
366
- // + userKey both present and `transcript` was requested).
367
- if (extra?.transcript && transcripts && userKey) {
368
- const produced = session.agent.messages.slice(messagesBefore);
369
- const text = produced
370
- .filter((m) => m.role === "assistant" &&
371
- typeof m.content === "string" &&
372
- m.content.trim().length > 0)
373
- .map((m) => m.content)
374
- .join("\n\n");
375
- if (text.length > 0) {
376
- await transcripts.append(this, { role: "assistant", text }, { userKey });
377
- }
257
+ }
258
+ const context = [
259
+ ...this.deps.context,
260
+ ...(transcriptContext ? [transcriptContext] : []),
261
+ ...(extra?.context ?? []),
262
+ ];
263
+ // Snapshot the message count BEFORE the loop so we can isolate the
264
+ // assistant messages this run produced (step 4).
265
+ const messagesBefore = session.agent.messages.length;
266
+ const startedAt = Date.now();
267
+ let loopResult;
268
+ // Telemetry stage: "agent" while the run loop runs, "finalize" for the
269
+ // transcript-append + renderer.finish() steps below. A throw in either is
270
+ // reported as agent_run_failed (with the right stage) instead of being
271
+ // hidden behind an already-sent success event.
272
+ let stage = "agent";
273
+ try {
274
+ loopResult = await runAgentLoop({
275
+ agent: session.agent,
276
+ renderer,
277
+ tools,
278
+ toolDescriptors,
279
+ context,
280
+ makeToolCtx: () => ({
281
+ thread: this,
282
+ platform: this.platform,
283
+ }),
284
+ handleInterrupt: async (interrupt) => {
285
+ const h = this.deps.interruptHandlers.get(interrupt.eventName);
286
+ if (h)
287
+ await h({ payload: interrupt.value, thread: this });
288
+ },
289
+ initialResume,
290
+ });
291
+ stage = "finalize";
292
+ // Transcript auto-bridge (step 4): capture the assistant text this run
293
+ // produced and append it. Only when the bridge actually applied (transcripts
294
+ // + userKey both present and `transcript` was requested).
295
+ if (extra?.transcript && transcripts && userKey) {
296
+ const produced = session.agent.messages.slice(messagesBefore);
297
+ const text = produced
298
+ .filter((m) => m.role === "assistant" &&
299
+ typeof m.content === "string" &&
300
+ m.content.trim().length > 0)
301
+ .map((m) => m.content)
302
+ .join("\n\n");
303
+ if (text.length > 0) {
304
+ await transcripts.append(this, { role: "assistant", text }, { userKey });
378
305
  }
379
- // Turn-end hook: lets a renderer finalize any turn-scoped resource it kept
380
- // open across runAgent iterations (e.g. a native streaming message). A
381
- // no-op for renderers whose per-message streams already self-terminate, and
382
- // for runs that were interrupted (the renderer guards that internally).
383
- await renderer.finish?.();
384
- }
385
- catch (err) {
386
- // A throw is a run failure — in the agent loop (tool-handler errors are
387
- // swallowed inside the loop, so a throw is agent-level) or in finalization.
388
- // `stage` distinguishes the two.
389
- this.deps.telemetry?.capture("oss.channel.agent_run_failed", {
390
- platform: normalizePlatform(this.platform),
391
- errorClass: errorClass(err),
392
- stage,
393
- });
394
- throw err;
395
306
  }
396
- // Emit success ONLY after the loop AND finalization both completed, so a
397
- // late transcript/finish rejection can never follow a success event.
398
- this.deps.telemetry?.capture("oss.channel.agent_run", {
307
+ // Turn-end hook: lets a renderer finalize any turn-scoped resource it kept
308
+ // open across runAgent iterations (e.g. a native streaming message). A
309
+ // no-op for renderers whose per-message streams already self-terminate, and
310
+ // for runs that were interrupted (the renderer guards that internally).
311
+ await renderer.finish?.();
312
+ }
313
+ catch (err) {
314
+ // A throw is a run failure — in the agent loop (tool-handler errors are
315
+ // swallowed inside the loop, so a throw is agent-level) or in finalization.
316
+ // `stage` distinguishes the two.
317
+ this.deps.telemetry?.capture("oss.channel.agent_run_failed", {
399
318
  platform: normalizePlatform(this.platform),
400
- durationMs: Date.now() - startedAt,
401
- toolCallCount: renderer.getCapturedToolCalls().length,
402
- iterations: loopResult.iterations,
403
- interrupted: loopResult.interrupted,
319
+ errorClass: errorClass(err),
320
+ stage,
404
321
  });
405
- return undefined;
406
- }
407
- finally {
408
- await session.release?.();
322
+ throw err;
409
323
  }
324
+ // Emit success ONLY after the loop AND finalization both completed, so a
325
+ // late transcript/finish rejection can never follow a success event.
326
+ this.deps.telemetry?.capture("oss.channel.agent_run", {
327
+ platform: normalizePlatform(this.platform),
328
+ durationMs: Date.now() - startedAt,
329
+ toolCallCount: renderer.getCapturedToolCalls().length,
330
+ iterations: loopResult.iterations,
331
+ interrupted: loopResult.interrupted,
332
+ });
333
+ return undefined;
410
334
  }
411
335
  }
412
336
  let transcriptWarned = false;