@pylonsync/functions 0.3.368 → 0.3.371

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 (29) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/og-fixture-GZKcjt/app/opengraph-image.d.ts +20 -0
  3. package/dist/og-fixture-GuniPy/app/opengraph-image.d.ts +1 -0
  4. package/dist/og-fixture-IqNlbN/app/opengraph-image.d.ts +20 -0
  5. package/dist/og-fixture-O6yotr/app/opengraph-image.d.ts +1 -0
  6. package/dist/og-fixture-bCvDUF/app/opengraph-image.d.ts +1 -0
  7. package/dist/og-fixture-fbkkcz/app/opengraph-image.d.ts +20 -0
  8. package/dist/og-fixture-oY8CpY/app/opengraph-image.d.ts +12 -0
  9. package/dist/og-fixture-osS4Ik/app/opengraph-image.d.ts +12 -0
  10. package/dist/og-fixture-vRTMew/app/opengraph-image.d.ts +20 -0
  11. package/dist/og-fixture-x2747s/app/opengraph-image.d.ts +12 -0
  12. package/dist/runtime.d.ts +22 -1
  13. package/dist/types.d.ts +82 -0
  14. package/package.json +1 -1
  15. package/src/index.ts +11 -0
  16. package/src/og-fixture-GZKcjt/app/opengraph-image.tsx +10 -0
  17. package/src/og-fixture-GuniPy/app/opengraph-image.tsx +1 -0
  18. package/src/og-fixture-IqNlbN/app/opengraph-image.tsx +10 -0
  19. package/src/og-fixture-O6yotr/app/opengraph-image.tsx +1 -0
  20. package/src/og-fixture-bCvDUF/app/opengraph-image.tsx +1 -0
  21. package/src/og-fixture-fbkkcz/app/opengraph-image.tsx +10 -0
  22. package/src/og-fixture-oY8CpY/app/opengraph-image.tsx +6 -0
  23. package/src/og-fixture-osS4Ik/app/opengraph-image.tsx +6 -0
  24. package/src/og-fixture-vRTMew/app/opengraph-image.tsx +10 -0
  25. package/src/og-fixture-x2747s/app/opengraph-image.tsx +6 -0
  26. package/src/runtime-llm-stream.test.ts +186 -0
  27. package/src/runtime.ts +110 -1
  28. package/src/ssr-runtime.test.ts +6 -1
  29. package/src/types.ts +81 -0
package/dist/index.d.ts CHANGED
@@ -22,4 +22,4 @@ export { v } from "./validators";
22
22
  export { resetDb, installTestIsolation } from "./testing";
23
23
  export { slugifyName, availableSlug } from "./slugify";
24
24
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
25
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, } from "./types";
25
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ export default function OG(): {
3
+ __pylonImageResponse: boolean;
4
+ element: React.DetailedReactHTMLElement<{
5
+ style: {
6
+ display: "flex";
7
+ width: string;
8
+ height: string;
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
13
+ options: {
14
+ width: number;
15
+ height: number;
16
+ headers: {
17
+ "cache-control": string;
18
+ };
19
+ };
20
+ };
@@ -0,0 +1 @@
1
+ export default function OG(): void;
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ export default function OG(): {
3
+ __pylonImageResponse: boolean;
4
+ element: React.DetailedReactHTMLElement<{
5
+ style: {
6
+ display: "flex";
7
+ width: string;
8
+ height: string;
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
13
+ options: {
14
+ width: number;
15
+ height: number;
16
+ headers: {
17
+ "cache-control": string;
18
+ };
19
+ };
20
+ };
@@ -0,0 +1 @@
1
+ export default function OG(): void;
@@ -0,0 +1 @@
1
+ export default function OG(): void;
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ export default function OG(): {
3
+ __pylonImageResponse: boolean;
4
+ element: React.DetailedReactHTMLElement<{
5
+ style: {
6
+ display: "flex";
7
+ width: string;
8
+ height: string;
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
13
+ options: {
14
+ width: number;
15
+ height: number;
16
+ headers: {
17
+ "cache-control": string;
18
+ };
19
+ };
20
+ };
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ export declare const size: {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ export default function OG(): React.DetailedReactHTMLElement<{
7
+ style: {
8
+ display: "flex";
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ export declare const size: {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ export default function OG(): React.DetailedReactHTMLElement<{
7
+ style: {
8
+ display: "flex";
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ export default function OG(): {
3
+ __pylonImageResponse: boolean;
4
+ element: React.DetailedReactHTMLElement<{
5
+ style: {
6
+ display: "flex";
7
+ width: string;
8
+ height: string;
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
13
+ options: {
14
+ width: number;
15
+ height: number;
16
+ headers: {
17
+ "cache-control": string;
18
+ };
19
+ };
20
+ };
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ export declare const size: {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ export default function OG(): React.DetailedReactHTMLElement<{
7
+ style: {
8
+ display: "flex";
9
+ fontSize: number;
10
+ fontFamily: "Inter";
11
+ };
12
+ }, HTMLElement>;
package/dist/runtime.d.ts CHANGED
@@ -14,6 +14,27 @@
14
14
  * each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
- import type { DbReader, DbWriter } from "./types";
17
+ import type { DbReader, DbWriter, Llm, Rooms } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
19
  export declare function buildDbWriter(callId: string): DbWriter;
20
+ /**
21
+ * Build the LLM client that round-trips through the host runtime.
22
+ *
23
+ * Each call emits an `llm_complete` protocol message; the runtime
24
+ * forwards to the configured provider (PYLON_LLM_PROVIDER) and replies
25
+ * with the parsed response. The host enforces model-allowlist gating
26
+ * for non-admin callers — that's why the API key never leaves the
27
+ * server process.
28
+ *
29
+ * Errors carry an `err.code` so handlers can branch on `LLM_NOT_CONFIGURED`
30
+ * vs `PROVIDER_HTTP_429` vs `MODEL_NOT_ALLOWED` without parsing message
31
+ * strings.
32
+ */
33
+ export declare function buildLlm(callId: string): Llm;
34
+ /**
35
+ * Build the room broadcaster. One `room_broadcast` message per call;
36
+ * the host fans the event out through the same RoomManager the
37
+ * `/api/rooms/*` routes use, so a server push and a member push land
38
+ * on subscribers identically.
39
+ */
40
+ export declare function buildRooms(callId: string): Rooms;
package/dist/types.d.ts CHANGED
@@ -276,6 +276,84 @@ export interface Llm {
276
276
  * `PROVIDER_UNREACHABLE`, `INVALID_REQUEST`.
277
277
  */
278
278
  complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse>;
279
+ /**
280
+ * Streaming completion. `onEvent` fires for each event as the
281
+ * provider emits it; the promise resolves with the same assembled
282
+ * response `complete` returns, so a tool-use loop can inspect
283
+ * `stop_reason` after the text has already been streamed out.
284
+ *
285
+ * The typical agent shape pumps deltas straight to the client:
286
+ *
287
+ * ```ts
288
+ * const res = await ctx.llm.stream(
289
+ * { messages, tools },
290
+ * (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); },
291
+ * );
292
+ * if (res.stop_reason === "tool_use") { ...run tools, loop... }
293
+ * ```
294
+ *
295
+ * Streaming does NOT extend the function's call deadline — it is an
296
+ * absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
297
+ * default). A long agent run must declare its own `timeout` on the
298
+ * function def.
299
+ *
300
+ * Same errors and same gating as {@link Llm.complete} — including
301
+ * the model allowlist, so streaming can't be used to reach a model
302
+ * `complete` would refuse.
303
+ */
304
+ stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void): Promise<LlmCompleteResponse>;
305
+ }
306
+ /**
307
+ * One event from an in-flight {@link Llm.stream} call.
308
+ *
309
+ * `tool_use_start` opens a tool call; the `tool_input_delta` events
310
+ * that follow carry its arguments as raw JSON fragments — concatenate
311
+ * them and parse once, rather than parsing each fragment. `done`
312
+ * always fires last, including on a partial failure.
313
+ */
314
+ export type LlmStreamEvent = {
315
+ type: "text_delta";
316
+ text: string;
317
+ } | {
318
+ type: "tool_use_start";
319
+ id: string;
320
+ name: string;
321
+ } | {
322
+ type: "tool_input_delta";
323
+ partial_json: string;
324
+ } | {
325
+ type: "done";
326
+ stop_reason: string;
327
+ usage: {
328
+ input_tokens: number;
329
+ output_tokens: number;
330
+ };
331
+ };
332
+ /**
333
+ * Server-originated realtime push. Broadcasts an event to every
334
+ * subscriber of a presence room — the same rooms clients join with
335
+ * `useRoom(roomId, userId)`, and the same delivery path a member's
336
+ * `broadcast()` uses.
337
+ *
338
+ * This is the surface for streaming agent output that must survive a
339
+ * closed tab or reach a second device: write tokens to the room, and
340
+ * every watcher gets them, not just the caller holding the HTTP
341
+ * response. `ctx.stream.write` reaches only the one client that made
342
+ * the call.
343
+ *
344
+ * Not available in queries — a reactive handler re-runs on every dep
345
+ * change, which would re-broadcast each time.
346
+ */
347
+ export interface Rooms {
348
+ /**
349
+ * Push `data` to every subscriber of `room` under `topic`.
350
+ * Resolves `{ delivered: false }` when the room has no members —
351
+ * broadcasting into an empty room is a no-op, not an error, so an
352
+ * agent doesn't need to know whether anyone is watching.
353
+ */
354
+ broadcast(room: string, topic: string, data?: unknown): Promise<{
355
+ delivered: boolean;
356
+ }>;
279
357
  }
280
358
  export interface LlmMessage {
281
359
  role: "user" | "assistant" | "system" | "tool";
@@ -469,6 +547,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
469
547
  env: Record<string, string>;
470
548
  /** Provider-abstracted LLM client. */
471
549
  llm: Llm;
550
+ /** Server-originated realtime push — see {@link Rooms}. */
551
+ rooms: Rooms;
472
552
  /** Per-user OAuth connection registry. */
473
553
  connections: Connections;
474
554
  /** Create a typed error that triggers rollback. */
@@ -485,6 +565,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
485
565
  email: EmailSender;
486
566
  /** Provider-abstracted LLM client. */
487
567
  llm: Llm;
568
+ /** Server-originated realtime push — see {@link Rooms}. */
569
+ rooms: Rooms;
488
570
  /** Per-user OAuth connection registry. */
489
571
  connections: Connections;
490
572
  /** Environment variables / secrets. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.368",
3
+ "version": "0.3.371",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -54,4 +54,15 @@ export type {
54
54
  RequireMember,
55
55
  RequireMemberOptions,
56
56
  MemberRow,
57
+ // LLM + realtime surfaces. A handler writing an agent tool loop
58
+ // builds its own message array and branches on stream events, so
59
+ // these have to be nameable from app code.
60
+ Llm,
61
+ LlmMessage,
62
+ LlmContentBlock,
63
+ LlmTool,
64
+ LlmCompleteRequest,
65
+ LlmCompleteResponse,
66
+ LlmStreamEvent,
67
+ Rooms,
57
68
  } from "./types";
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ export default function OG() {
3
+ return {
4
+ __pylonImageResponse: true,
5
+ element: React.createElement("div",
6
+ { style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
7
+ "Branded"),
8
+ options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
9
+ };
10
+ }
@@ -0,0 +1 @@
1
+ export default function OG() { throw new Error("boom"); }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ export default function OG() {
3
+ return {
4
+ __pylonImageResponse: true,
5
+ element: React.createElement("div",
6
+ { style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
7
+ "Branded"),
8
+ options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
9
+ };
10
+ }
@@ -0,0 +1 @@
1
+ export default function OG() { throw new Error("boom"); }
@@ -0,0 +1 @@
1
+ export default function OG() { throw new Error("boom"); }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ export default function OG() {
3
+ return {
4
+ __pylonImageResponse: true,
5
+ element: React.createElement("div",
6
+ { style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
7
+ "Branded"),
8
+ options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
9
+ };
10
+ }
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ export const size = { width: 640, height: 360 };
3
+ export default function OG() {
4
+ return React.createElement("div",
5
+ { style: { display: "flex", fontSize: 32, fontFamily: "Inter" } }, "Bare");
6
+ }
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ export const size = { width: 640, height: 360 };
3
+ export default function OG() {
4
+ return React.createElement("div",
5
+ { style: { display: "flex", fontSize: 32, fontFamily: "Inter" } }, "Bare");
6
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ export default function OG() {
3
+ return {
4
+ __pylonImageResponse: true,
5
+ element: React.createElement("div",
6
+ { style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
7
+ "Branded"),
8
+ options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
9
+ };
10
+ }
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ export const size = { width: 640, height: 360 };
3
+ export default function OG() {
4
+ return React.createElement("div",
5
+ { style: { display: "flex", fontSize: 32, fontFamily: "Inter" } }, "Bare");
6
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for `ctx.llm.stream` and `ctx.rooms.broadcast` (runtime.ts).
3
+ *
4
+ * The contract under test:
5
+ *
6
+ * 1. `stream()` emits ONE `llm_stream` frame carrying an `op_id`.
7
+ * 2. `llm_event` frames addressed to that op_id reach the caller's
8
+ * `onEvent` in order, WITHOUT settling the promise.
9
+ * 3. The terminal `result` for that op_id resolves the promise with
10
+ * the assembled response.
11
+ * 4. Events for a different op_id never leak into this stream's
12
+ * callback — two concurrent streams stay separated.
13
+ * 5. A throwing `onEvent` doesn't abandon the RPC; the stream still
14
+ * resolves.
15
+ * 6. `rooms.broadcast` emits a `room_broadcast` frame and resolves
16
+ * with the host's `{delivered}` verdict.
17
+ *
18
+ * runtime.ts runs main() on import (it IS the bun runner entrypoint),
19
+ * so this drives the REAL dispatcher in a child process: we write host
20
+ * frames to its stdin and read what it emits on stdout, exactly as the
21
+ * Rust host would.
22
+ */
23
+ import { expect, test } from "bun:test";
24
+ import { mkdtempSync, writeFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+
28
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
29
+
30
+ const SCRIPT = `
31
+ import { buildLlm, buildRooms } from ${JSON.stringify(RUNTIME)};
32
+
33
+ const llm = buildLlm("c_1");
34
+ const rooms = buildRooms("c_2");
35
+
36
+ const seen = [];
37
+ const other = [];
38
+
39
+ // Stream A — the one under test. Its onEvent records every event.
40
+ const a = llm.stream({ messages: [] }, (e) => { seen.push(e); });
41
+ // Stream B — concurrent, and its callback THROWS. Neither may disturb A.
42
+ const b = llm.stream({ messages: [] }, (e) => {
43
+ other.push(e);
44
+ throw new Error("handler callback blew up");
45
+ });
46
+ const r = rooms.broadcast("room-1", "agent.delta", { text: "hi" });
47
+
48
+ const [aRes, bRes, rRes] = await Promise.all([a, b, r]);
49
+
50
+ console.error("RESULT " + JSON.stringify({
51
+ seen,
52
+ other,
53
+ aStop: aRes.stop_reason,
54
+ bStop: bRes.stop_reason,
55
+ delivered: rRes.delivered,
56
+ }));
57
+ process.exit(0);
58
+ `;
59
+
60
+ /** Collect NDJSON frames from a chunk of the child's stdout. */
61
+ function parseFrames(text: string): Record<string, unknown>[] {
62
+ return text
63
+ .split("\n")
64
+ .filter((l) => l.trim().startsWith("{"))
65
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
66
+ }
67
+
68
+ test("llm.stream routes events by op_id, resolves on result; rooms.broadcast round-trips", async () => {
69
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-llm-"));
70
+ const scriptPath = join(dir, "probe.ts");
71
+ writeFileSync(scriptPath, SCRIPT);
72
+
73
+ // Spawn the bun that's already running, by absolute path — resolving
74
+ // "bun" off PATH depends on the process cwd, which other test files
75
+ // in this suite move around.
76
+ const proc = Bun.spawn([process.execPath, scriptPath], {
77
+ stdin: "pipe",
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+
82
+ // Read the child's request frames incrementally — we need its op_ids
83
+ // before we can address replies back to it.
84
+ const reader = proc.stdout.getReader();
85
+ const decoder = new TextDecoder();
86
+ let buffered = "";
87
+ // Wait for the three frames we care about. main() also emits a
88
+ // one-shot `ready` handshake on import, so count by type rather than
89
+ // by total frames.
90
+ let requests: Record<string, unknown>[] = [];
91
+ const wanted = (fs: Record<string, unknown>[]) =>
92
+ fs.filter((f) => f.type === "llm_stream").length >= 2 &&
93
+ fs.some((f) => f.type === "room_broadcast");
94
+ while (!wanted(requests)) {
95
+ const { done, value } = await reader.read();
96
+ if (done) break;
97
+ buffered += decoder.decode(value, { stream: true });
98
+ requests = parseFrames(buffered);
99
+ }
100
+
101
+ const streams = requests.filter((f) => f.type === "llm_stream");
102
+ const broadcasts = requests.filter((f) => f.type === "room_broadcast");
103
+ expect(streams).toHaveLength(2);
104
+ expect(broadcasts).toHaveLength(1);
105
+
106
+ // 1. Each stream frame carries a distinct op_id — that's what makes
107
+ // concurrent streams routable.
108
+ const opA = streams[0].op_id as string;
109
+ const opB = streams[1].op_id as string;
110
+ expect(opA).toBeTruthy();
111
+ expect(opB).toBeTruthy();
112
+ expect(opA).not.toBe(opB);
113
+
114
+ // 2. Broadcast frame carries the room, topic, and payload verbatim.
115
+ expect(broadcasts[0]).toMatchObject({
116
+ room: "room-1",
117
+ topic: "agent.delta",
118
+ data: { text: "hi" },
119
+ });
120
+
121
+ const send = (msg: Record<string, unknown>) =>
122
+ proc.stdin.write(JSON.stringify(msg) + "\n");
123
+
124
+ // Interleave A's and B's events so a routing bug (e.g. keying on
125
+ // call_id, or a single global sink) shows up as cross-talk.
126
+ send({ type: "llm_event", call_id: "c_1", op_id: opA, event: { type: "text_delta", text: "He" } });
127
+ send({ type: "llm_event", call_id: "c_1", op_id: opB, event: { type: "text_delta", text: "XX" } });
128
+ send({ type: "llm_event", call_id: "c_1", op_id: opA, event: { type: "text_delta", text: "llo" } });
129
+ send({
130
+ type: "llm_event",
131
+ call_id: "c_1",
132
+ op_id: opA,
133
+ event: { type: "tool_use_start", id: "t1", name: "search" },
134
+ });
135
+ send({
136
+ type: "llm_event",
137
+ call_id: "c_1",
138
+ op_id: opA,
139
+ event: { type: "tool_input_delta", partial_json: '{"q":' },
140
+ });
141
+
142
+ // Terminal results settle each stream.
143
+ send({
144
+ type: "result",
145
+ call_id: "c_1",
146
+ op_id: opA,
147
+ data: { model: "m", content: [], stop_reason: "tool_use", usage: { input_tokens: 1, output_tokens: 2 } },
148
+ });
149
+ send({
150
+ type: "result",
151
+ call_id: "c_1",
152
+ op_id: opB,
153
+ data: { model: "m", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 } },
154
+ });
155
+ send({
156
+ type: "result",
157
+ call_id: "c_2",
158
+ op_id: broadcasts[0].op_id,
159
+ data: { delivered: true },
160
+ });
161
+ await proc.stdin.flush();
162
+
163
+ const stderr = await new Response(proc.stderr).text();
164
+ await proc.exited;
165
+
166
+ const line = stderr.split("\n").find((l) => l.includes("RESULT "));
167
+ expect(line).toBeDefined();
168
+ const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
169
+
170
+ // 3+4. A saw exactly its own events, in order — B's "XX" never leaked.
171
+ expect(out.seen).toEqual([
172
+ { type: "text_delta", text: "He" },
173
+ { type: "text_delta", text: "llo" },
174
+ { type: "tool_use_start", id: "t1", name: "search" },
175
+ { type: "tool_input_delta", partial_json: '{"q":' },
176
+ ]);
177
+ expect(out.other).toEqual([{ type: "text_delta", text: "XX" }]);
178
+
179
+ // 5. Both promises resolved with their OWN response — including B,
180
+ // whose callback threw on every event.
181
+ expect(out.aStop).toBe("tool_use");
182
+ expect(out.bStop).toBe("end_turn");
183
+
184
+ // 6. rooms.broadcast surfaced the host's verdict.
185
+ expect(out.delivered).toBe(true);
186
+ });
package/src/runtime.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
24
  Llm,
25
25
  LlmCompleteRequest,
26
26
  LlmCompleteResponse,
27
+ LlmStreamEvent,
28
+ Rooms,
27
29
  Connections,
28
30
  QueryCtx,
29
31
  MutationCtx,
@@ -175,6 +177,13 @@ const pendingRpcs = new Map<
175
177
  }
176
178
  >();
177
179
 
180
+ /**
181
+ * Event sinks for in-flight streaming RPCs, keyed by op_id. Separate
182
+ * from `pendingRpcs` because these fire many times and never settle
183
+ * the promise — the terminal `result` message does that.
184
+ */
185
+ const streamSinks = new Map<string, (event: unknown) => void>();
186
+
178
187
  let opSeq = 0;
179
188
  function nextOpId(callId: string): string {
180
189
  opSeq += 1;
@@ -307,6 +316,14 @@ function dispatch(line: string): void {
307
316
  error: err?.message || String(err),
308
317
  });
309
318
  });
319
+ } else if (msg.type === "llm_event") {
320
+ const ev = msg as unknown as {
321
+ call_id: string;
322
+ op_id?: string;
323
+ event: unknown;
324
+ };
325
+ const sink = streamSinks.get(ev.op_id ?? ev.call_id);
326
+ if (sink) sink(ev.event);
310
327
  } else if (msg.type === "result") {
311
328
  const res = msg as unknown as ResultMessage & { op_id?: string };
312
329
  // Prefer op_id when the host sent it. Fall back to call_id for replies
@@ -353,6 +370,63 @@ function rpcDb(
353
370
  });
354
371
  }
355
372
 
373
+ /**
374
+ * RPC that receives interim events before its terminal reply
375
+ * (`ctx.llm.stream`). Mints an op_id like {@link rpcDb}, and also
376
+ * registers an event sink the dispatcher routes `llm_event` messages
377
+ * to. The promise resolves only on the final `result`.
378
+ *
379
+ * Each event RESTARTS the idle timeout: a stream that keeps producing
380
+ * is alive, however long it runs in total, while one whose provider
381
+ * goes silent still trips the safety net. The host's own call deadline
382
+ * remains the real upper bound.
383
+ */
384
+ function rpcStreaming(
385
+ callId: string,
386
+ msg: Record<string, unknown>,
387
+ onEvent: (event: unknown) => void,
388
+ ): Promise<unknown> {
389
+ const opId = nextOpId(callId);
390
+ return new Promise((resolve, reject) => {
391
+ const fail = () => {
392
+ if (pendingRpcs.has(opId)) {
393
+ pendingRpcs.delete(opId);
394
+ streamSinks.delete(opId);
395
+ reject(
396
+ new Error(
397
+ `RPC timed out after ${RPC_TIMEOUT_MS}ms with no stream activity (call_id=${callId} op_id=${opId})`,
398
+ ),
399
+ );
400
+ }
401
+ };
402
+ const entry = {
403
+ resolve: (data: unknown) => {
404
+ streamSinks.delete(opId);
405
+ resolve(data);
406
+ },
407
+ reject: (err: Error) => {
408
+ streamSinks.delete(opId);
409
+ reject(err);
410
+ },
411
+ timeout: setTimeout(fail, RPC_TIMEOUT_MS),
412
+ };
413
+ pendingRpcs.set(opId, entry);
414
+ streamSinks.set(opId, (event) => {
415
+ clearTimeout(entry.timeout);
416
+ entry.timeout = setTimeout(fail, RPC_TIMEOUT_MS);
417
+ // A throwing sink must not abandon the pending RPC — the host is
418
+ // still going to send a terminal result, and swallowing here
419
+ // keeps the handler's own error the one that surfaces.
420
+ try {
421
+ onEvent(event);
422
+ } catch {
423
+ /* handler-side callback error; the stream continues */
424
+ }
425
+ });
426
+ send({ ...msg, call_id: callId, op_id: opId });
427
+ });
428
+ }
429
+
356
430
  /**
357
431
  * RPC for non-db protocol replies (scheduler.runAfter, nested function
358
432
  * calls, etc.) where at-most-one in-flight per call_id is the right
@@ -657,7 +731,7 @@ function buildEmail(callId: string): EmailSender {
657
731
  * vs `PROVIDER_HTTP_429` vs `MODEL_NOT_ALLOWED` without parsing message
658
732
  * strings.
659
733
  */
660
- function buildLlm(callId: string): Llm {
734
+ export function buildLlm(callId: string): Llm {
661
735
  return {
662
736
  async complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse> {
663
737
  return (await rpc(callId, {
@@ -665,6 +739,36 @@ function buildLlm(callId: string): Llm {
665
739
  request,
666
740
  })) as LlmCompleteResponse;
667
741
  },
742
+
743
+ async stream(
744
+ request: LlmCompleteRequest,
745
+ onEvent: (event: LlmStreamEvent) => void,
746
+ ): Promise<LlmCompleteResponse> {
747
+ return (await rpcStreaming(
748
+ callId,
749
+ { type: "llm_stream", request },
750
+ (event) => onEvent(event as LlmStreamEvent),
751
+ )) as LlmCompleteResponse;
752
+ },
753
+ };
754
+ }
755
+
756
+ /**
757
+ * Build the room broadcaster. One `room_broadcast` message per call;
758
+ * the host fans the event out through the same RoomManager the
759
+ * `/api/rooms/*` routes use, so a server push and a member push land
760
+ * on subscribers identically.
761
+ */
762
+ export function buildRooms(callId: string): Rooms {
763
+ return {
764
+ async broadcast(room: string, topic: string, data?: unknown) {
765
+ return (await rpcDb(callId, {
766
+ type: "room_broadcast",
767
+ room,
768
+ topic,
769
+ data: data ?? {},
770
+ })) as { delivered: boolean };
771
+ },
668
772
  };
669
773
  }
670
774
 
@@ -740,6 +844,7 @@ function buildActionCtx(
740
844
  scheduler: Scheduler,
741
845
  email: EmailSender,
742
846
  llm: Llm,
847
+ rooms: Rooms,
743
848
  connections: Connections,
744
849
  request?: unknown
745
850
  ): ActionCtx {
@@ -762,6 +867,7 @@ function buildActionCtx(
762
867
  scheduler,
763
868
  email,
764
869
  llm,
870
+ rooms,
765
871
  connections,
766
872
  env: process.env as Record<string, string>,
767
873
  async runQuery(fnName, args) {
@@ -866,6 +972,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
866
972
  const scheduler = buildScheduler(msg.call_id);
867
973
  const email = buildEmail(msg.call_id);
868
974
  const llm = buildLlm(msg.call_id);
975
+ const rooms = buildRooms(msg.call_id);
869
976
  const connections = buildConnections(msg.call_id);
870
977
 
871
978
  // Normalize the Rust-side auth envelope (snake_case) to the camelCase
@@ -942,6 +1049,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
942
1049
  scheduler,
943
1050
  env,
944
1051
  llm,
1052
+ rooms,
945
1053
  connections,
946
1054
  error(code, message) {
947
1055
  const err = new Error(message);
@@ -962,6 +1070,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
962
1070
  scheduler,
963
1071
  email,
964
1072
  llm,
1073
+ rooms,
965
1074
  connections,
966
1075
  (msg as unknown as { request?: unknown }).request,
967
1076
  );
@@ -279,7 +279,12 @@ const tmpDirs: string[] = [];
279
279
  function makeApp(): string {
280
280
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-og-"));
281
281
  tmpDirs.push(dir);
282
- prevCwd = process.cwd();
282
+ // Only the FIRST call records the restore target. A second makeApp()
283
+ // in the same test would otherwise capture the previous temp dir,
284
+ // and afterEach would chdir back into it right before deleting it —
285
+ // leaving the whole process on a deleted cwd, which breaks every
286
+ // later test file that spawns a subprocess.
287
+ if (prevCwd === null) prevCwd = process.cwd();
283
288
  process.chdir(dir);
284
289
  return dir;
285
290
  }
package/src/types.ts CHANGED
@@ -353,6 +353,83 @@ export interface Llm {
353
353
  * `PROVIDER_UNREACHABLE`, `INVALID_REQUEST`.
354
354
  */
355
355
  complete(request: LlmCompleteRequest): Promise<LlmCompleteResponse>;
356
+
357
+ /**
358
+ * Streaming completion. `onEvent` fires for each event as the
359
+ * provider emits it; the promise resolves with the same assembled
360
+ * response `complete` returns, so a tool-use loop can inspect
361
+ * `stop_reason` after the text has already been streamed out.
362
+ *
363
+ * The typical agent shape pumps deltas straight to the client:
364
+ *
365
+ * ```ts
366
+ * const res = await ctx.llm.stream(
367
+ * { messages, tools },
368
+ * (e) => { if (e.type === "text_delta") ctx.stream.write(e.text); },
369
+ * );
370
+ * if (res.stop_reason === "tool_use") { ...run tools, loop... }
371
+ * ```
372
+ *
373
+ * Streaming does NOT extend the function's call deadline — it is an
374
+ * absolute wall clock from invocation (PYLON_FN_CALL_TIMEOUT, 30s
375
+ * default). A long agent run must declare its own `timeout` on the
376
+ * function def.
377
+ *
378
+ * Same errors and same gating as {@link Llm.complete} — including
379
+ * the model allowlist, so streaming can't be used to reach a model
380
+ * `complete` would refuse.
381
+ */
382
+ stream(
383
+ request: LlmCompleteRequest,
384
+ onEvent: (event: LlmStreamEvent) => void,
385
+ ): Promise<LlmCompleteResponse>;
386
+ }
387
+
388
+ /**
389
+ * One event from an in-flight {@link Llm.stream} call.
390
+ *
391
+ * `tool_use_start` opens a tool call; the `tool_input_delta` events
392
+ * that follow carry its arguments as raw JSON fragments — concatenate
393
+ * them and parse once, rather than parsing each fragment. `done`
394
+ * always fires last, including on a partial failure.
395
+ */
396
+ export type LlmStreamEvent =
397
+ | { type: "text_delta"; text: string }
398
+ | { type: "tool_use_start"; id: string; name: string }
399
+ | { type: "tool_input_delta"; partial_json: string }
400
+ | {
401
+ type: "done";
402
+ stop_reason: string;
403
+ usage: { input_tokens: number; output_tokens: number };
404
+ };
405
+
406
+ /**
407
+ * Server-originated realtime push. Broadcasts an event to every
408
+ * subscriber of a presence room — the same rooms clients join with
409
+ * `useRoom(roomId, userId)`, and the same delivery path a member's
410
+ * `broadcast()` uses.
411
+ *
412
+ * This is the surface for streaming agent output that must survive a
413
+ * closed tab or reach a second device: write tokens to the room, and
414
+ * every watcher gets them, not just the caller holding the HTTP
415
+ * response. `ctx.stream.write` reaches only the one client that made
416
+ * the call.
417
+ *
418
+ * Not available in queries — a reactive handler re-runs on every dep
419
+ * change, which would re-broadcast each time.
420
+ */
421
+ export interface Rooms {
422
+ /**
423
+ * Push `data` to every subscriber of `room` under `topic`.
424
+ * Resolves `{ delivered: false }` when the room has no members —
425
+ * broadcasting into an empty room is a no-op, not an error, so an
426
+ * agent doesn't need to know whether anyone is watching.
427
+ */
428
+ broadcast(
429
+ room: string,
430
+ topic: string,
431
+ data?: unknown,
432
+ ): Promise<{ delivered: boolean }>;
356
433
  }
357
434
 
358
435
  export interface LlmMessage {
@@ -563,6 +640,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
563
640
  env: Record<string, string>;
564
641
  /** Provider-abstracted LLM client. */
565
642
  llm: Llm;
643
+ /** Server-originated realtime push — see {@link Rooms}. */
644
+ rooms: Rooms;
566
645
  /** Per-user OAuth connection registry. */
567
646
  connections: Connections;
568
647
  /** Create a typed error that triggers rollback. */
@@ -580,6 +659,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
580
659
  email: EmailSender;
581
660
  /** Provider-abstracted LLM client. */
582
661
  llm: Llm;
662
+ /** Server-originated realtime push — see {@link Rooms}. */
663
+ rooms: Rooms;
583
664
  /** Per-user OAuth connection registry. */
584
665
  connections: Connections;
585
666
  /** Environment variables / secrets. */