@junejs/server 0.1.2-dev.1 → 0.1.2-dev.3

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.
@@ -1,5 +1,5 @@
1
1
  import { Channel, ChannelFactory } from "@junejs/core/agent-config";
2
- import { InboundEvent, Model, Msg, SessionStore, Tool } from "@junejs/core/agent-runtime";
2
+ import { InboundEvent, Model, Msg, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
3
3
  import { Resources } from "@junejs/core/resources";
4
4
  //#region src/agent-durable.d.ts
5
5
  type SqlStorageValue = ArrayBuffer | string | number | null;
@@ -62,6 +62,8 @@ declare class AgentDurableObject {
62
62
  transcript(): import("@junejs/core/agent-runtime").Turn[];
63
63
  fetch(req: Request): Promise<Response>;
64
64
  }
65
+ declare function sseTurnFinalText(res: Response): Promise<string>;
66
+ declare function sseTurnEvents(res: Response): AsyncIterable<TurnEvent>;
65
67
  declare function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response>;
66
68
  declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
67
69
  agentName: string;
@@ -71,7 +73,8 @@ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespac
71
73
  agentName: string;
72
74
  channels: (Channel | ChannelFactory)[];
73
75
  env: unknown;
76
+ services?: (env: unknown) => unknown;
74
77
  waitUntil?: (p: Promise<unknown>) => void;
75
78
  }): (req: Request) => Promise<Response | null>;
76
79
  //#endregion
77
- export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch };
80
+ export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -44,12 +44,12 @@ var DoSessionStore = class {
44
44
  return this.sql;
45
45
  }
46
46
  };
47
- var InProcBroadcaster = class {
47
+ var InProcEventSink = class {
48
48
  subs = /* @__PURE__ */ new Set();
49
- publish(turnId) {
49
+ emit(e) {
50
50
  this.subs.forEach((cb) => {
51
51
  try {
52
- cb(turnId);
52
+ cb(e);
53
53
  } catch {}
54
54
  });
55
55
  }
@@ -78,7 +78,7 @@ var AgentDurableObject = class {
78
78
  seen.add(t.spec.name);
79
79
  tools.push(t);
80
80
  }
81
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcBroadcaster(), model, tools, crossDoUnsupported, def.channelInstructions);
81
+ this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
82
82
  }
83
83
  async turn(input) {
84
84
  await ensureScope();
@@ -94,16 +94,101 @@ var AgentDurableObject = class {
94
94
  const url = new URL(req.url);
95
95
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
96
96
  const { userText, turnId, event } = await req.json();
97
- return Response.json({ text: await this.turn({
97
+ await ensureScope();
98
+ const started = runInScope({
99
+ resources: this.resources,
100
+ services: this.services
101
+ }, () => this.session.start({
98
102
  userText,
99
103
  turnId,
100
104
  event
101
- }) });
105
+ }));
106
+ return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
102
107
  }
103
108
  if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
104
109
  return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
105
110
  }
106
111
  };
112
+ const SSE_HEADERS = {
113
+ "content-type": "text/event-stream",
114
+ "cache-control": "no-store"
115
+ };
116
+ function sseTurnStream(session, turnId) {
117
+ const enc = new TextEncoder();
118
+ let unsub;
119
+ let heartbeat;
120
+ const stop = () => {
121
+ unsub?.();
122
+ clearInterval(heartbeat);
123
+ };
124
+ return new ReadableStream({
125
+ start(controller) {
126
+ heartbeat = setInterval(() => {
127
+ try {
128
+ controller.enqueue(enc.encode(":hb\n\n"));
129
+ } catch {
130
+ clearInterval(heartbeat);
131
+ }
132
+ }, 2e4);
133
+ unsub = session.observe((e) => {
134
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
135
+ if (e.type === "turn.completed" || e.type === "turn.failed") {
136
+ stop();
137
+ controller.close();
138
+ }
139
+ }, { turnId });
140
+ },
141
+ cancel() {
142
+ stop();
143
+ }
144
+ });
145
+ }
146
+ async function sseTurnFinalText(res) {
147
+ const ct = res.headers.get("content-type") ?? "";
148
+ if (!res.body || !ct.includes("text/event-stream")) {
149
+ const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
150
+ throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
151
+ }
152
+ const reader = res.body.getReader();
153
+ const dec = new TextDecoder();
154
+ let buf = "";
155
+ for (;;) {
156
+ const { done, value } = await reader.read();
157
+ if (value) buf += dec.decode(value, { stream: true });
158
+ let i;
159
+ while ((i = buf.indexOf("\n\n")) >= 0) {
160
+ const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
161
+ buf = buf.slice(i + 2);
162
+ if (!line) continue;
163
+ const e = JSON.parse(line.slice(5).trim());
164
+ if (e.type === "turn.completed") return e.text;
165
+ if (e.type === "turn.failed") throw new Error(e.error.message);
166
+ }
167
+ if (done) break;
168
+ }
169
+ throw new Error("turn stream ended without a terminal event");
170
+ }
171
+ async function* sseTurnEvents(res) {
172
+ const ct = res.headers.get("content-type") ?? "";
173
+ if (!res.body || !ct.includes("text/event-stream")) {
174
+ const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
175
+ throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
176
+ }
177
+ const reader = res.body.getReader();
178
+ const dec = new TextDecoder();
179
+ let buf = "";
180
+ for (;;) {
181
+ const { done, value } = await reader.read();
182
+ if (value) buf += dec.decode(value, { stream: true });
183
+ let i;
184
+ while ((i = buf.indexOf("\n\n")) >= 0) {
185
+ const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
186
+ buf = buf.slice(i + 2);
187
+ if (line) yield JSON.parse(line.slice(5).trim());
188
+ }
189
+ if (done) break;
190
+ }
191
+ }
107
192
  function durableFetch(namespace, agent, session, req) {
108
193
  return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
109
194
  }
@@ -115,10 +200,12 @@ function durableAgentSurface(getNamespace, opts) {
115
200
  const namespace = getNamespace();
116
201
  if (!namespace) return null;
117
202
  const { message, session } = await req.json();
118
- return durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
203
+ const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
119
204
  method: "POST",
120
205
  body: JSON.stringify({ userText: message })
121
206
  }));
207
+ if (req.headers.get("accept")?.includes("text/event-stream")) return res;
208
+ return Response.json({ text: await sseTurnFinalText(res) });
122
209
  };
123
210
  }
124
211
  function durableChannelSurface(getNamespace, opts) {
@@ -131,15 +218,23 @@ function durableChannelSurface(getNamespace, opts) {
131
218
  channels: [],
132
219
  connections: []
133
220
  },
221
+ services: opts.services?.(opts.env),
134
222
  waitUntil: opts.waitUntil,
135
223
  run: async (message, o) => {
136
224
  const namespace = getNamespace();
137
225
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
138
- const { text } = await (await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
226
+ return sseTurnFinalText(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
227
+ method: "POST",
228
+ body: serializeTurn(message, o)
229
+ })));
230
+ },
231
+ runStream: async function* (message, o) {
232
+ const namespace = getNamespace();
233
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
234
+ yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
139
235
  method: "POST",
140
236
  body: serializeTurn(message, o)
141
- }))).json();
142
- return text;
237
+ })));
143
238
  }
144
239
  });
145
240
  }
@@ -163,4 +258,4 @@ function serializeTurn(userText, o) {
163
258
  }
164
259
  }
165
260
  //#endregion
166
- export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch };
261
+ export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -31,6 +31,7 @@ declare function createAgentRuntime(agents: Record<string, AgentDef>, opts?: {
31
31
  declare function mountAgent(agent: AgentDefinition, runtime: Runtime, opts?: {
32
32
  chatPath?: string;
33
33
  channels?: boolean;
34
+ services?: unknown;
34
35
  }): {
35
36
  surface: (req: Request) => Promise<Response | null>;
36
37
  fetch: (req: Request) => Promise<Response | null>;
@@ -51,12 +51,12 @@ var SqliteSessionStore = class {
51
51
  return this.db;
52
52
  }
53
53
  };
54
- var InProcBroadcaster = class {
54
+ var InProcEventSink = class {
55
55
  subs = /* @__PURE__ */ new Set();
56
- publish(turnId) {
56
+ emit(e) {
57
57
  this.subs.forEach((cb) => {
58
58
  try {
59
- cb(turnId);
59
+ cb(e);
60
60
  } catch {}
61
61
  });
62
62
  }
@@ -81,7 +81,7 @@ var NativeRuntime = class {
81
81
  const def = this.agents[agent];
82
82
  if (!def) throw new Error(`unknown agent: ${agent}`);
83
83
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
84
- a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcBroadcaster(), model, def.tools, this, def.channelInstructions);
84
+ a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcEventSink(), model, def.tools, this, def.channelInstructions);
85
85
  this.actors.set(key, a);
86
86
  }
87
87
  return a;
@@ -135,7 +135,7 @@ var MemoryRuntime = class {
135
135
  if (!def) throw new Error(`unknown agent: ${agent}`);
136
136
  const store = new MemorySessionStore();
137
137
  this.stores.set(key, store);
138
- a = new AgentSession(agent, id, store, new InProcBroadcaster(), def.model, def.tools, this);
138
+ a = new AgentSession(agent, id, store, new InProcEventSink(), def.model, def.tools, this);
139
139
  this.actors.set(key, a);
140
140
  }
141
141
  return a;
@@ -152,6 +152,7 @@ function mountAgent(agent, runtime, opts = {}) {
152
152
  const channelsOn = opts.channels ?? true;
153
153
  const ctx = {
154
154
  agent,
155
+ services: opts.services,
155
156
  run: (message, o) => runtime.session(agent.name, o?.session ?? "default").turn({
156
157
  turnId: o?.turnId,
157
158
  userText: message,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.2-dev.1",
3
+ "version": "0.1.2-dev.3",
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",
@@ -125,7 +125,7 @@
125
125
  "build": "tsdown"
126
126
  },
127
127
  "dependencies": {
128
- "@junejs/core": ">=0.1.1-dev.3",
128
+ "@junejs/core": ">=0.1.1-dev.5",
129
129
  "@junejs/db": ">=0.0.34-dev.0",
130
130
  "@momiji-rs/sparkdown": "^0.0.4",
131
131
  "lightningcss": "^1.27.0",
@@ -18,7 +18,8 @@
18
18
  import {
19
19
  AgentSession,
20
20
  withSystem,
21
- type Broadcaster,
21
+ type EventSink,
22
+ type TurnEvent,
22
23
  type Model,
23
24
  type Msg,
24
25
  type Runtime,
@@ -119,10 +120,10 @@ export class DoSessionStore implements SessionStore {
119
120
  }
120
121
  }
121
122
 
122
- class InProcBroadcaster implements Broadcaster {
123
- private subs = new Set<(t: string) => void>();
124
- publish(turnId: string) { this.subs.forEach((cb) => { try { cb(turnId); } catch { /* a bad subscriber must not break publish */ } }); }
125
- subscribe(cb: (t: string) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
123
+ class InProcEventSink implements EventSink {
124
+ private subs = new Set<(e: TurnEvent) => void>();
125
+ emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
126
+ subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
126
127
  }
127
128
 
128
129
  // A DO can't run in-process subagents; a subagent there is a sibling DO (cross-DO
@@ -203,7 +204,7 @@ export class AgentDurableObject {
203
204
  seen.add(t.spec.name);
204
205
  tools.push(t);
205
206
  }
206
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcBroadcaster(), model, tools, crossDoUnsupported, def.channelInstructions);
207
+ this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
207
208
  }
208
209
  // Run the whole turn inside a request scope seeded from this DO's env, so ambient
209
210
  // `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
@@ -219,19 +220,109 @@ export class AgentDurableObject {
219
220
  transcript() {
220
221
  return this.session.transcript();
221
222
  }
222
- // Default HTTP surface: POST …/turn runs a durable turn; GET …/transcript reads
223
- // the log. The app can call turn()/transcript() directly instead.
223
+ // Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
224
+ // turn in-scope, then stream from the session sink); GET …/transcript reads the log.
225
+ // The app can call turn()/transcript() directly instead (turn() awaits the final text).
224
226
  async fetch(req: Request): Promise<Response> {
225
227
  const url = new URL(req.url);
226
228
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
227
229
  const { userText, turnId, event } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent };
228
- return Response.json({ text: await this.turn({ userText, turnId, event }) });
230
+ await ensureScope();
231
+ // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
232
+ // db/services (ALS propagates to the .then continuation registered here); subscribing
233
+ // happens synchronously right after, before any event can emit.
234
+ const started = runInScope({ resources: this.resources, services: this.services }, () => this.session.start({ userText, turnId, event }));
235
+ return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
229
236
  }
230
237
  if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
231
238
  return new Response("agent DO — POST /turn or GET /transcript", { status: 404 });
232
239
  }
233
240
  }
234
241
 
242
+ // ── SSE transport for the turn event stream (crosses the worker→DO isolate) ───
243
+ // The DO streams a turn's TurnEvents as text/event-stream; the worker either pipes
244
+ // that straight to a browser (live chat) or collapses it to the final text (channels).
245
+ // no-store (not no-cache): an intermediary must NOT store chat/user content at all, and it
246
+ // matches the repo's other SSE surface (dev-reload).
247
+ const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-store" };
248
+
249
+ // A ReadableStream of SSE frames from a session's live event stream, scoped to one turn
250
+ // and closed on its terminal event. Subscribes synchronously (no replay needed — we start
251
+ // the turn then subscribe before any event can emit). A `:hb` comment heartbeat keeps the
252
+ // connection alive across long model/tool gaps (a silent SSE stream gets culled by idle
253
+ // timeouts on hosts/proxies) — cleared on terminal close and on cancel.
254
+ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Uint8Array> {
255
+ const enc = new TextEncoder();
256
+ let unsub: (() => void) | undefined;
257
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
258
+ const stop = () => { unsub?.(); clearInterval(heartbeat); };
259
+ return new ReadableStream<Uint8Array>({
260
+ start(controller) {
261
+ heartbeat = setInterval(() => {
262
+ try { controller.enqueue(enc.encode(":hb\n\n")); } catch { clearInterval(heartbeat); }
263
+ }, 20_000);
264
+ unsub = session.observe((e) => {
265
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
266
+ if (e.type === "turn.completed" || e.type === "turn.failed") { stop(); controller.close(); }
267
+ }, { turnId });
268
+ },
269
+ cancel() { stop(); },
270
+ });
271
+ }
272
+
273
+ // Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
274
+ // The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
275
+ // body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
276
+ export async function sseTurnFinalText(res: Response): Promise<string> {
277
+ const ct = res.headers.get("content-type") ?? "";
278
+ if (!res.body || !ct.includes("text/event-stream")) {
279
+ const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
280
+ throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
281
+ }
282
+ const reader = res.body.getReader();
283
+ const dec = new TextDecoder();
284
+ let buf = "";
285
+ for (;;) {
286
+ const { done, value } = await reader.read();
287
+ if (value) buf += dec.decode(value, { stream: true });
288
+ let i: number;
289
+ while ((i = buf.indexOf("\n\n")) >= 0) {
290
+ const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
291
+ buf = buf.slice(i + 2);
292
+ if (!line) continue;
293
+ const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
294
+ if (e.type === "turn.completed") return e.text;
295
+ if (e.type === "turn.failed") throw new Error(e.error.message);
296
+ }
297
+ if (done) break;
298
+ }
299
+ throw new Error("turn stream ended without a terminal event");
300
+ }
301
+
302
+ // Parse an SSE turn response into the stream of TurnEvents (skipping `:hb` heartbeats).
303
+ // The streaming consumer (a channel's render path) iterates this to drive live UI.
304
+ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
305
+ const ct = res.headers.get("content-type") ?? "";
306
+ if (!res.body || !ct.includes("text/event-stream")) {
307
+ const detail = res.body ? (await res.text()).slice(0, 200) : "no body";
308
+ throw new Error(`turn stream: expected an SSE response, got ${ct || "no content-type"} (status ${res.status}): ${detail}`);
309
+ }
310
+ const reader = res.body.getReader();
311
+ const dec = new TextDecoder();
312
+ let buf = "";
313
+ for (;;) {
314
+ const { done, value } = await reader.read();
315
+ if (value) buf += dec.decode(value, { stream: true });
316
+ let i: number;
317
+ while ((i = buf.indexOf("\n\n")) >= 0) {
318
+ const line = buf.slice(0, i).split("\n").find((l) => l.startsWith("data:"));
319
+ buf = buf.slice(i + 2);
320
+ if (line) yield JSON.parse(line.slice(5).trim()) as TurnEvent;
321
+ }
322
+ if (done) break;
323
+ }
324
+ }
325
+
235
326
  // Worker-side routing: address a session's DO by (agent, session) and forward the
236
327
  // request to it. `env.AGENT` is the DO namespace binding.
237
328
  export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
@@ -257,13 +348,17 @@ export function durableAgentSurface(
257
348
  const namespace = getNamespace();
258
349
  if (!namespace) return null; // no DO binding → not mounted here
259
350
  const { message, session } = (await req.json()) as { message: string; session?: string };
260
- // Forward to the session's DO on its /turn contract (AgentDurableObject.fetch).
261
- return durableFetch(
351
+ // Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
352
+ // that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
353
+ // through; otherwise collapse to the final { text } (the prior JSON contract).
354
+ const res = await durableFetch(
262
355
  namespace,
263
356
  opts.agentName,
264
357
  session ?? "default",
265
358
  new Request("https://do/turn", { method: "POST", body: JSON.stringify({ userText: message }) }),
266
359
  );
360
+ if (req.headers.get("accept")?.includes("text/event-stream")) return res;
361
+ return Response.json({ text: await sseTurnFinalText(res) });
267
362
  };
268
363
  }
269
364
 
@@ -299,6 +394,11 @@ export function durableChannelSurface(
299
394
  agentName: string;
300
395
  channels: (Channel | ChannelFactory)[];
301
396
  env: unknown;
397
+ // The app's services factory — the SAME one AgentDurableObject uses — resolved HERE
398
+ // (worker isolate) and exposed to channel hooks as ctx.services. Channel hooks run at
399
+ // the edge, outside the DO, so they can't read the DO's ambient currentServices(); this
400
+ // gives them the same DI bag. Resolved once per surface construction.
401
+ services?: (env: unknown) => unknown;
302
402
  waitUntil?: (p: Promise<unknown>) => void;
303
403
  },
304
404
  ): (req: Request) => Promise<Response | null> {
@@ -307,6 +407,7 @@ export function durableChannelSurface(
307
407
  // A minimal but complete AgentDefinition — channels only read ctx.agent.name; the
308
408
  // full def isn't present in the worker (tools/model live in the DO).
309
409
  agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
410
+ services: opts.services?.(opts.env),
310
411
  waitUntil: opts.waitUntil,
311
412
  run: async (message, o) => {
312
413
  const namespace = getNamespace();
@@ -317,8 +418,20 @@ export function durableChannelSurface(
317
418
  o?.session ?? "default",
318
419
  new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
319
420
  );
320
- const { text } = (await res.json()) as { text: string };
321
- return text;
421
+ // /turn streams SSE; the simple path collapses it to the final text.
422
+ return sseTurnFinalText(res);
423
+ },
424
+ // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
425
+ runStream: async function* (message, o) {
426
+ const namespace = getNamespace();
427
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
428
+ const res = await durableFetch(
429
+ namespace,
430
+ opts.agentName,
431
+ o?.session ?? "default",
432
+ new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
433
+ );
434
+ yield* sseTurnEvents(res);
322
435
  },
323
436
  };
324
437
  return channelDispatch(resolved, ctx);
@@ -10,7 +10,8 @@
10
10
  import {
11
11
  AgentSession,
12
12
  withSystem,
13
- type Broadcaster,
13
+ type EventSink,
14
+ type TurnEvent,
14
15
  type Model,
15
16
  type Msg,
16
17
  type Runtime,
@@ -77,10 +78,10 @@ class SqliteSessionStore implements SessionStore {
77
78
  unwrap<H = unknown>(): H { return this.db as unknown as H; }
78
79
  }
79
80
 
80
- class InProcBroadcaster implements Broadcaster {
81
- private subs = new Set<(t: string) => void>();
82
- publish(turnId: string) { this.subs.forEach((cb) => { try { cb(turnId); } catch { /* a bad subscriber must not break publish */ } }); }
83
- subscribe(cb: (t: string) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
81
+ class InProcEventSink implements EventSink {
82
+ private subs = new Set<(e: TurnEvent) => void>();
83
+ emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
84
+ subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
84
85
  }
85
86
 
86
87
  // `instructions` (the agent's system prompt) is injected into the model per turn
@@ -107,7 +108,7 @@ export class NativeRuntime implements Runtime {
107
108
  const def = this.agents[agent];
108
109
  if (!def) throw new Error(`unknown agent: ${agent}`);
109
110
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
110
- a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcBroadcaster(), model, def.tools, this, def.channelInstructions);
111
+ a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcEventSink(), model, def.tools, this, def.channelInstructions);
111
112
  this.actors.set(key, a);
112
113
  }
113
114
  return a;
@@ -159,7 +160,7 @@ export class MemoryRuntime implements Runtime {
159
160
  if (!def) throw new Error(`unknown agent: ${agent}`);
160
161
  const store = new MemorySessionStore();
161
162
  this.stores.set(key, store);
162
- a = new AgentSession(agent, id, store, new InProcBroadcaster(), def.model, def.tools, this);
163
+ a = new AgentSession(agent, id, store, new InProcEventSink(), def.model, def.tools, this);
163
164
  this.actors.set(key, a);
164
165
  }
165
166
  return a;
@@ -195,7 +196,7 @@ export async function createAgentRuntime(
195
196
  export function mountAgent(
196
197
  agent: AgentDefinition,
197
198
  runtime: Runtime,
198
- opts: { chatPath?: string; channels?: boolean } = {},
199
+ opts: { chatPath?: string; channels?: boolean; services?: unknown } = {},
199
200
  ): {
200
201
  surface: (req: Request) => Promise<Response | null>;
201
202
  fetch: (req: Request) => Promise<Response | null>;
@@ -206,6 +207,7 @@ export function mountAgent(
206
207
  const channelsOn = opts.channels ?? true;
207
208
  const ctx: ChannelContext = {
208
209
  agent,
210
+ services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
209
211
  run: (message, o) =>
210
212
  runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event }),
211
213
  };