@junejs/server 1.0.0-dev.11 → 1.0.0-dev.13

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.
@@ -66,6 +66,7 @@ declare class AgentDurableObject {
66
66
  private readonly sink;
67
67
  private readonly model;
68
68
  private readonly tools;
69
+ private readonly channels;
69
70
  private readonly channelInstructions?;
70
71
  private readonly resources;
71
72
  private readonly services;
@@ -1,6 +1,6 @@
1
1
  import { isolateLocal } from "./isolate-local.js";
2
2
  import { assertCoreRuntimeVersion } from "./core-version.js";
3
- import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
3
+ import { DeliverUnsupportedError, channelDispatch, resolveChannel } from "@junejs/core/agent-config";
4
4
  import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
5
5
  import { ensureScope, runInScope } from "@junejs/db";
6
6
  //#region src/agent-durable.ts
@@ -81,6 +81,7 @@ var AgentDurableObject = class {
81
81
  sink;
82
82
  model;
83
83
  tools;
84
+ channels;
84
85
  channelInstructions;
85
86
  resources;
86
87
  services;
@@ -112,7 +113,8 @@ var AgentDurableObject = class {
112
113
  }
113
114
  defaultLog();
114
115
  });
115
- const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
116
+ const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
117
+ const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
116
118
  const tools = [...def.tools];
117
119
  const seen = new Set(tools.map((t) => t.spec.name));
118
120
  for (const t of channelTools) {
@@ -125,6 +127,7 @@ var AgentDurableObject = class {
125
127
  this.sink = sink;
126
128
  this.model = model;
127
129
  this.tools = tools;
130
+ this.channels = channels;
128
131
  this.channelInstructions = def.channelInstructions;
129
132
  }
130
133
  resolveSession(key) {
@@ -168,6 +171,13 @@ var AgentDurableObject = class {
168
171
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
169
172
  const { userText, turnId, event, trigger } = await req.json();
170
173
  await ensureScope();
174
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
175
+ let deliverChannel;
176
+ if (wantsDeliver) {
177
+ if (!event?.channelId) return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
178
+ deliverChannel = this.channels.find((c) => c.name === event.source);
179
+ if (!deliverChannel?.deliver) return Response.json({ error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` }, { status: 501 });
180
+ }
171
181
  let session;
172
182
  let started;
173
183
  try {
@@ -184,12 +194,41 @@ var AgentDurableObject = class {
184
194
  } catch (err) {
185
195
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
186
196
  }
197
+ if (wantsDeliver) {
198
+ const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
199
+ try {
200
+ const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
201
+ const events = { [Symbol.asyncIterator]: () => it };
202
+ const target = {
203
+ channelId: event.channelId,
204
+ threadId: event.threadId,
205
+ recipientUserId: event.user?.id,
206
+ recipientTeamId: event.teamId
207
+ };
208
+ Promise.resolve().then(() => runInScope({
209
+ resources: this.resources,
210
+ services: this.services
211
+ }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
212
+ it.return?.();
213
+ });
214
+ } catch (err) {
215
+ logRenderFailure(err);
216
+ }
217
+ return Response.json({ turnId: started.turnId }, { status: 202 });
218
+ }
187
219
  if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
188
220
  return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
189
221
  }
190
222
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
191
- const { turnId, inputId, input, by } = await req.json();
223
+ const { turnId, inputId, input, by, source, target } = await req.json();
192
224
  await ensureScope();
225
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
226
+ let resumeChannel;
227
+ if (wantsDeliver) {
228
+ if (!source || !target?.channelId || !target.messageTs) return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
229
+ resumeChannel = this.channels.find((c) => c.name === source);
230
+ if (!resumeChannel?.deliverResume) return Response.json({ error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` }, { status: 501 });
231
+ }
193
232
  let session;
194
233
  try {
195
234
  session = this.resolveSession(key);
@@ -201,6 +240,22 @@ var AgentDurableObject = class {
201
240
  const message = err instanceof Error ? err.message : String(err);
202
241
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
203
242
  }
243
+ if (wantsDeliver) {
244
+ const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
245
+ try {
246
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
247
+ const events = { [Symbol.asyncIterator]: () => it };
248
+ Promise.resolve().then(() => runInScope({
249
+ resources: this.resources,
250
+ services: this.services
251
+ }, () => resumeChannel.deliverResume(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
252
+ it.return?.();
253
+ });
254
+ } catch (err) {
255
+ logRenderFailure(err);
256
+ }
257
+ return Response.json({ turnId }, { status: 202 });
258
+ }
204
259
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
205
260
  }
206
261
  if (url.pathname.endsWith("/transcript")) {
@@ -248,6 +303,49 @@ function sseTurnStream(session, turnId) {
248
303
  }
249
304
  });
250
305
  }
306
+ function observeTurnEvents(session, turnId) {
307
+ const queue = [];
308
+ let terminal = false;
309
+ let notify;
310
+ const unsub = session.observe((e) => {
311
+ queue.push(e);
312
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
313
+ notify?.();
314
+ }, { turnId });
315
+ return { [Symbol.asyncIterator]() {
316
+ return {
317
+ async next() {
318
+ for (;;) {
319
+ const e = queue.shift();
320
+ if (e) return {
321
+ value: e,
322
+ done: false
323
+ };
324
+ if (terminal) {
325
+ unsub();
326
+ return {
327
+ value: void 0,
328
+ done: true
329
+ };
330
+ }
331
+ await new Promise((resolve) => {
332
+ notify = resolve;
333
+ });
334
+ notify = void 0;
335
+ }
336
+ },
337
+ async return() {
338
+ unsub();
339
+ terminal = true;
340
+ queue.length = 0;
341
+ return {
342
+ value: void 0,
343
+ done: true
344
+ };
345
+ }
346
+ };
347
+ } };
348
+ }
251
349
  async function sseTurnFinalText(res) {
252
350
  const ct = res.headers.get("content-type") ?? "";
253
351
  if (!res.body || !ct.includes("text/event-stream")) {
@@ -371,6 +469,20 @@ function durableChannelSurface(getNamespace, opts) {
371
469
  }
372
470
  return await res.json();
373
471
  },
472
+ runDelivered: async (message, o) => {
473
+ const namespace = getNamespace();
474
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
475
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?deliver=1", {
476
+ method: "POST",
477
+ body: serializeTurn(message, o)
478
+ }));
479
+ if (res.status === 501) throw new DeliverUnsupportedError(`runDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this turn"}`);
480
+ if (res.status !== 202) {
481
+ const detail = (await res.text()).slice(0, 200);
482
+ throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
483
+ }
484
+ return await res.json();
485
+ },
374
486
  runStream: async function* (message, o) {
375
487
  const namespace = getNamespace();
376
488
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
@@ -386,6 +498,20 @@ function durableChannelSurface(getNamespace, opts) {
386
498
  method: "POST",
387
499
  body: serializeResume(o)
388
500
  })));
501
+ },
502
+ resumeDelivered: async (o) => {
503
+ const namespace = getNamespace();
504
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
505
+ const res = await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume?deliver=1", {
506
+ method: "POST",
507
+ body: serializeResume(o)
508
+ }));
509
+ if (res.status === 501) throw new DeliverUnsupportedError(`resumeDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this continuation"}`);
510
+ if (res.status !== 202) {
511
+ const detail = (await res.text()).slice(0, 200);
512
+ throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
513
+ }
514
+ return await res.json();
389
515
  }
390
516
  });
391
517
  }
@@ -395,10 +521,12 @@ function serializeResume(o) {
395
521
  turnId: o.turnId,
396
522
  inputId: o.inputId,
397
523
  input: o.input,
398
- by: o.by
524
+ by: o.by,
525
+ source: o.source,
526
+ target: o.target
399
527
  });
400
528
  } catch (err) {
401
- throw new Error(`resumeStream: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
529
+ throw new Error(`resume: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
402
530
  }
403
531
  }
404
532
  function serializeTurn(userText, o) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.11",
3
+ "version": "1.0.0-dev.13",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -132,7 +132,7 @@
132
132
  "rolldown": "^1.1.0"
133
133
  },
134
134
  "peerDependencies": {
135
- "@junejs/core": ">=0.2.0-dev.26",
135
+ "@junejs/core": ">=0.2.0-dev.30",
136
136
  "react": "^19.2.0",
137
137
  "react-dom": "^19.2.0",
138
138
  "react-server-dom-webpack": "19.2.7",
@@ -34,11 +34,13 @@ import {
34
34
  import type { Resources } from "@junejs/core/resources";
35
35
  import {
36
36
  channelDispatch,
37
+ DeliverUnsupportedError,
37
38
  resolveChannel,
38
39
  type AgentDefinition,
39
40
  type Channel,
40
41
  type ChannelContext,
41
42
  type ChannelFactory,
43
+ type ResumeDeliveryTarget,
42
44
  } from "@junejs/core/agent-config";
43
45
  import { ensureScope, runInScope } from "@junejs/db";
44
46
  import { isolateLocal } from "./isolate-local";
@@ -227,6 +229,7 @@ export class AgentDurableObject {
227
229
  private readonly sink: InProcEventSink;
228
230
  private readonly model: Model;
229
231
  private readonly tools: Tool[];
232
+ private readonly channels: Channel[];
230
233
  private readonly channelInstructions?: Record<string, string>;
231
234
  // Built from the DO's env in the constructor (this isolate), shared across turns —
232
235
  // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
@@ -277,8 +280,11 @@ export class AgentDurableObject {
277
280
  });
278
281
  // Merge the mounted channels' capability tools (built here from this DO's env, since a
279
282
  // tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
280
- // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
281
- const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
283
+ // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe. The
284
+ // resolved channels are also RETAINED (this.channels): a delivered turn (/turn?deliver=1)
285
+ // renders its reply through the source channel's deliver() from inside this DO.
286
+ const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
287
+ const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
282
288
  const tools: Tool[] = [...def.tools];
283
289
  const seen = new Set(tools.map((t) => t.spec.name));
284
290
  for (const t of channelTools) {
@@ -291,6 +297,7 @@ export class AgentDurableObject {
291
297
  this.sink = sink;
292
298
  this.model = model;
293
299
  this.tools = tools;
300
+ this.channels = channels;
294
301
  this.channelInstructions = def.channelInstructions;
295
302
  }
296
303
  // Resolve THE session for this DO: explicit key (from a routed request) → persisted
@@ -371,6 +378,26 @@ export class AgentDurableObject {
371
378
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
372
379
  const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
373
380
  await ensureScope();
381
+ // DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
382
+ // dropped: this DO renders the turn's event stream through the source channel's own
383
+ // deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
384
+ // BEFORE the turn starts — that ordering is DeliverUnsupportedError's contract at the
385
+ // surface: on a pre-start rejection the caller may fall back to consumer-side rendering
386
+ // without double-running the turn.
387
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
388
+ let deliverChannel: Channel | undefined;
389
+ if (wantsDeliver) {
390
+ if (!event?.channelId) {
391
+ return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
392
+ }
393
+ deliverChannel = this.channels.find((c) => c.name === event.source);
394
+ if (!deliverChannel?.deliver) {
395
+ return Response.json(
396
+ { error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` },
397
+ { status: 501 },
398
+ );
399
+ }
400
+ }
374
401
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
375
402
  // db/services (ALS propagates to the .then continuation registered here); subscribing
376
403
  // happens synchronously right after, before any event can emit.
@@ -384,6 +411,41 @@ export class AgentDurableObject {
384
411
  // object's identity — a client-resolvable conflict, not a crash
385
412
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
386
413
  }
414
+ if (wantsDeliver) {
415
+ // Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
416
+ // the turn, no event can have emitted yet), then render under THIS DO's lifetime —
417
+ // the retained promise is pending work that keeps the DO alive, so a long turn's
418
+ // rendering survives however briefly the caller held its connection. The event is
419
+ // present (checked above); its author/thread are the reply target, exactly the
420
+ // surface the channel's inbound renderStream derives.
421
+ //
422
+ // Once start() has queued the turn, NOTHING here may escape: a throw would 500 the
423
+ // caller for a turn that runs anyway — the exact ambiguity delivered turns exist to
424
+ // remove. deliver() is app/third-party code that can throw synchronously before
425
+ // returning its promise, so it is deferred into the chain (the eager subscription
426
+ // above stays synchronous — that timing guarantee must not move); the try/catch
427
+ // backstops the subscription itself. Rendering failures have no live consumer —
428
+ // this log is their only surfacing path (the TURN's own failures already surface
429
+ // via the #76 sink subscription).
430
+ const logRenderFailure = (err: unknown) =>
431
+ console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
432
+ try {
433
+ // Own the iterator here: observeTurnEvents subscribes EAGERLY, and a deliver()
434
+ // that throws before ever iterating would otherwise leave that subscription
435
+ // attached, buffering this turn's events forever. return() is idempotent, so
436
+ // closing in finally is safe whether deliver consumed the stream or died early.
437
+ const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
438
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
439
+ const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
440
+ Promise.resolve()
441
+ .then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
442
+ .catch(logRenderFailure)
443
+ .finally(() => { void it.return?.(); });
444
+ } catch (err) {
445
+ logRenderFailure(err);
446
+ }
447
+ return Response.json({ turnId: started.turnId }, { status: 202 });
448
+ }
387
449
  // DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
388
450
  // this DO's lifetime (alive while work is pending), so its duration is no longer
389
451
  // bounded by however long the CALLER can hold a connection (the edge waitUntil
@@ -396,8 +458,31 @@ export class AgentDurableObject {
396
458
  // VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
397
459
  // signature-checked Slack interaction payload), never expose this endpoint raw to clients.
398
460
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
399
- const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
461
+ const { turnId, inputId, input, by, source, target } = (await req.json()) as {
462
+ turnId: string; inputId: string; input: unknown; by?: string;
463
+ source?: string; target?: ResumeDeliveryTarget;
464
+ };
400
465
  await ensureScope();
466
+ // DELIVERED resume: like /turn?deliver=1, the caller 202s away and THIS DO renders the
467
+ // continuation through the source channel's deliverResume() — a long continuation no
468
+ // longer dies with the webhook isolate. Capability is checked BEFORE resume() applies
469
+ // the answer — the DeliverUnsupportedError contract at the surface: on a pre-effect
470
+ // refusal the caller may fall back to consumer-side rendering without double-answering
471
+ // (a second resume of an already-applied input would 409).
472
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
473
+ let resumeChannel: Channel | undefined;
474
+ if (wantsDeliver) {
475
+ if (!source || !target?.channelId || !target.messageTs) {
476
+ return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
477
+ }
478
+ resumeChannel = this.channels.find((c) => c.name === source);
479
+ if (!resumeChannel?.deliverResume) {
480
+ return Response.json(
481
+ { error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` },
482
+ { status: 501 },
483
+ );
484
+ }
485
+ }
401
486
  let session: AgentSession;
402
487
  try {
403
488
  session = this.resolveSession(key);
@@ -407,6 +492,28 @@ export class AgentDurableObject {
407
492
  // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
408
493
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
409
494
  }
495
+ if (wantsDeliver) {
496
+ // Same post-acceptance discipline as delivered turns: subscribe synchronously, render
497
+ // under this DO's lifetime, and let NOTHING escape past the accepted resume — the log
498
+ // is the render failure's only surfacing path (the TURN's own failures surface via
499
+ // the #76 sink subscription).
500
+ const logRenderFailure = (err: unknown) =>
501
+ console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
502
+ try {
503
+ // Own the iterator (same rationale as the delivered-turn path): the eager
504
+ // subscription must be released even when deliverResume throws before
505
+ // iterating — e.g. its very first message update fails.
506
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
507
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
508
+ Promise.resolve()
509
+ .then(() => runInScope({ resources: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
510
+ .catch(logRenderFailure)
511
+ .finally(() => { void it.return?.(); });
512
+ } catch (err) {
513
+ logRenderFailure(err);
514
+ }
515
+ return Response.json({ turnId }, { status: 202 });
516
+ }
410
517
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
411
518
  }
412
519
  if (url.pathname.endsWith("/transcript")) {
@@ -458,6 +565,45 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
458
565
  });
459
566
  }
460
567
 
568
+ // The IN-PROCESS sibling of sseTurnStream: one turn's TurnEvents as an AsyncIterable, for a
569
+ // consumer living in the SAME isolate as the session (a delivered render). Subscribes eagerly
570
+ // at call time — not at first iteration — so the sseTurnStream timing guarantee carries over
571
+ // (call this synchronously after start(), before any event can emit); events landing before
572
+ // the consumer catches up are buffered. Ends after the turn's terminal event (completed,
573
+ // failed, or input.requested — a park ends this stream; a later /resume is a new one), and an
574
+ // early consumer exit (for-await break/return) unsubscribes rather than buffering forever.
575
+ function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable<TurnEvent> {
576
+ const queue: TurnEvent[] = [];
577
+ let terminal = false;
578
+ let notify: (() => void) | undefined;
579
+ const unsub = session.observe((e) => {
580
+ queue.push(e);
581
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
582
+ notify?.();
583
+ }, { turnId });
584
+ return {
585
+ [Symbol.asyncIterator]() {
586
+ return {
587
+ async next(): Promise<IteratorResult<TurnEvent>> {
588
+ for (;;) {
589
+ const e = queue.shift();
590
+ if (e) return { value: e, done: false };
591
+ if (terminal) { unsub(); return { value: undefined, done: true }; }
592
+ await new Promise<void>((resolve) => { notify = resolve; });
593
+ notify = undefined;
594
+ }
595
+ },
596
+ async return(): Promise<IteratorResult<TurnEvent>> {
597
+ unsub();
598
+ terminal = true;
599
+ queue.length = 0;
600
+ return { value: undefined, done: true };
601
+ },
602
+ };
603
+ },
604
+ };
605
+ }
606
+
461
607
  // Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
462
608
  // The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
463
609
  // body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
@@ -705,6 +851,30 @@ export function durableChannelSurface(
705
851
  }
706
852
  return (await res.json()) as { turnId: string };
707
853
  },
854
+ // DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
855
+ // reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
856
+ // turn escapes the edge waitUntil ceiling exactly as a shadow turn does. A 501 means the
857
+ // DO refused BEFORE starting the turn (the channel isn't wired there / has no deliver) —
858
+ // surfaced as DeliverUnsupportedError, the one rejection a channel may answer with a
859
+ // consumer-side rendering fallback without double-running the turn.
860
+ runDelivered: async (message, o) => {
861
+ const namespace = getNamespace();
862
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
863
+ const res = await durableFetch(
864
+ namespace,
865
+ opts.agentName,
866
+ o?.session ?? "default",
867
+ new Request("https://do/turn?deliver=1", { method: "POST", body: serializeTurn(message, o) }),
868
+ );
869
+ if (res.status === 501) {
870
+ throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
871
+ }
872
+ if (res.status !== 202) {
873
+ const detail = (await res.text()).slice(0, 200);
874
+ throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
875
+ }
876
+ return (await res.json()) as { turnId: string };
877
+ },
708
878
  // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
709
879
  runStream: async function* (message, o) {
710
880
  const namespace = getNamespace();
@@ -729,6 +899,31 @@ export function durableChannelSurface(
729
899
  );
730
900
  yield* sseTurnEvents(res);
731
901
  },
902
+ // DELIVERED resume (runDelivered's sibling for the HITL leg): the DO applies the answer
903
+ // AND renders the continuation through the source channel's deliverResume() under its
904
+ // OWN lifetime. A 501 means the DO refused BEFORE applying the answer (channel not
905
+ // wired there / no deliverResume) — surfaced as DeliverUnsupportedError, the one
906
+ // rejection a channel may answer with a consumer-side fallback without double-answering.
907
+ // Engine rejections (403 unauthorized clicker / 409 stale-or-double click) throw as
908
+ // ordinary errors, mirroring what resumeStream's first pull would do.
909
+ resumeDelivered: async (o) => {
910
+ const namespace = getNamespace();
911
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
912
+ const res = await durableFetch(
913
+ namespace,
914
+ opts.agentName,
915
+ o.session ?? "default",
916
+ new Request("https://do/resume?deliver=1", { method: "POST", body: serializeResume(o) }),
917
+ );
918
+ if (res.status === 501) {
919
+ throw new DeliverUnsupportedError(`resumeDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this continuation"}`);
920
+ }
921
+ if (res.status !== 202) {
922
+ const detail = (await res.text()).slice(0, 200);
923
+ throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
924
+ }
925
+ return (await res.json()) as { turnId: string };
926
+ },
732
927
  };
733
928
  return channelDispatch(resolved, ctx);
734
929
  }
@@ -743,11 +938,12 @@ export function durableChannelSurface(
743
938
  // (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
744
939
  // Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
745
940
  // turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
746
- function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
941
+ function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string; source?: string; target?: ResumeDeliveryTarget }): string {
747
942
  try {
748
- return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
943
+ return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, source: o.source, target: o.target });
749
944
  } catch (err) {
750
- throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
945
+ // API-neutral prefix: this serializer backs both resumeStream and resumeDelivered.
946
+ throw new Error(`resume: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
751
947
  }
752
948
  }
753
949