okengine 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +1 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/reference/fx.mdx +27 -22
  11. package/src/cli/ai-setup/recommend.test.ts +25 -0
  12. package/src/cli/ai-setup/recommend.ts +8 -3
  13. package/src/compiler/effects-infer.ts +58 -3
  14. package/src/compiler/extract.test.ts +54 -1
  15. package/src/compiler/extract.ts +69 -2
  16. package/src/compiler/response.ts +45 -1
  17. package/src/console/server/ai.ts +5 -2
  18. package/src/console/server/flows.ts +1 -0
  19. package/src/console/server/serve.ts +17 -2
  20. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  21. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  22. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  23. package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
  24. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  25. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  26. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  27. package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
  28. package/src/console/ui-next/dist/index.html +1 -1
  29. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  30. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  31. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  32. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  33. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  34. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  35. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  36. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  37. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  38. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  39. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  40. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  41. package/src/docker/docker.test.ts +1 -1
  42. package/src/docker/dockerfile.ts +1 -1
  43. package/src/drivers/ai-anthropic.ts +5 -0
  44. package/src/drivers/ai-ollama.ts +49 -30
  45. package/src/drivers/ai-openai-compatible.ts +57 -46
  46. package/src/drivers/ai-providers.test.ts +3 -0
  47. package/src/drivers/bun-native-completeness.test.ts +7 -9
  48. package/src/drivers/redis.ts +11 -4
  49. package/src/drivers/signal-redis.ts +24 -14
  50. package/src/drivers/signal-types.ts +2 -1
  51. package/src/elements/ai/declare.ts +109 -0
  52. package/src/elements/ai/errors.test.ts +5 -1
  53. package/src/elements/ai/errors.ts +30 -2
  54. package/src/elements/ai/eval.ts +4 -6
  55. package/src/elements/ai/mcp-client.test.ts +206 -0
  56. package/src/elements/ai/mcp-client.ts +362 -0
  57. package/src/elements/ai/mcp-http.ts +159 -0
  58. package/src/elements/ai/mcp-mock.ts +134 -0
  59. package/src/elements/ai/mcp-protocol.ts +234 -0
  60. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  61. package/src/elements/ai/mcp-stdio.ts +212 -0
  62. package/src/elements/ai/mcp-transport.ts +70 -0
  63. package/src/elements/ai/runtime.ts +159 -29
  64. package/src/elements/ai.test.ts +139 -0
  65. package/src/elements/ai.ts +16 -0
  66. package/src/elements/clock/health.test.ts +43 -0
  67. package/src/elements/clock/runtime.ts +55 -2
  68. package/src/elements/clock/schedule.ts +71 -142
  69. package/src/elements/clock.test.ts +9 -40
  70. package/src/elements/clock.ts +6 -1
  71. package/src/elements/index.ts +2 -0
  72. package/src/elements/signal/declare.ts +2 -1
  73. package/src/elements/signal/runtime.ts +16 -1
  74. package/src/elements/signal.ts +1 -0
  75. package/src/elements/store/cache.test.ts +2 -0
  76. package/src/elements/store/cache.ts +3 -3
  77. package/src/elements/vault/builtin-adapter.ts +17 -0
  78. package/src/full.ts +2 -0
  79. package/src/index.ts +4 -0
  80. package/src/kernel/app.ts +97 -64
  81. package/src/kernel/auto-registry.test.ts +5 -0
  82. package/src/kernel/boot-bind/ai.ts +24 -0
  83. package/src/kernel/boot-bind/clock.ts +2 -0
  84. package/src/kernel/boot.ts +3 -1
  85. package/src/kernel/element-registries.ts +4 -1
  86. package/src/kernel/fx-dead-letters.test.ts +77 -0
  87. package/src/kernel/fx.test.ts +22 -0
  88. package/src/kernel/fx.ts +112 -6
  89. package/src/kernel/http-stream.test.ts +174 -0
  90. package/src/kernel/index.ts +2 -0
  91. package/src/manifest/mcp-ref.ts +88 -0
  92. package/src/manifest/types.ts +28 -2
  93. package/src/manifest/validate.test.ts +20 -0
  94. package/src/mcp/docs-server.ts +1 -1
  95. package/src/mcp/server.ts +1 -1
  96. package/src/plugins/compression.test.ts +21 -0
  97. package/src/plugins/compression.ts +1 -0
  98. package/src/runtime/bun.ts +41 -4
  99. package/src/test/reset-element-registries.ts +2 -0
  100. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  101. package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
  102. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  103. package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
  104. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  105. package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
@@ -361,11 +361,17 @@ describe("fx — wholesale swap", () => {
361
361
  async emit(signal) {
362
362
  calls.push(`emit:${typeof signal === "string" ? signal : signal.name}`);
363
363
  },
364
+ async deadLetters() {
365
+ return [];
366
+ },
364
367
  async call() {
365
368
  return null;
366
369
  },
367
370
  clock: {
368
371
  now: () => 0,
372
+ ago: () => 0,
373
+ fromNow: () => 0,
374
+ duration: () => 0,
369
375
  sleep: async () => undefined,
370
376
  },
371
377
  vault: {
@@ -451,6 +457,9 @@ describe("fx — wholesale swap", () => {
451
457
  withQuery(rows) {
452
458
  return { [jsonResultBrand]: true, status: 200, value: [...rows], meta: {} };
453
459
  },
460
+ stream(chunks) {
461
+ return { [jsonResultBrand]: true, kind: "stream" as const, status: 200 as const, chunks };
462
+ },
454
463
  },
455
464
  async step(_name, fn) {
456
465
  return fn();
@@ -514,6 +523,19 @@ describe("fx.t — catalogs", () => {
514
523
  });
515
524
  });
516
525
 
526
+ describe("fx.clock — relative instants", () => {
527
+ test("ago / fromNow / duration use the injected now and duration strings", () => {
528
+ const fx = createFx({ flow: "sessions.sweepExpired", effects: {}, now: () => 1_000_000 });
529
+ expect(fx.clock.now()).toBe(1_000_000);
530
+ expect(fx.clock.duration("30d")).toBe(30 * 86_400_000);
531
+ expect(fx.clock.ago("30d")).toBe(1_000_000 - 30 * 86_400_000);
532
+ expect(fx.clock.fromNow("14d")).toBe(1_000_000 + 14 * 86_400_000);
533
+ expect(fx.clock.duration("nope")).toBe(0);
534
+ expect(fx.clock.ago("nope")).toBe(1_000_000);
535
+ expect(fx.clock.fromNow("200ms")).toBe(1_000_200);
536
+ });
537
+ });
538
+
517
539
  describe("fx.json", () => {
518
540
  test("with accepts a page or (data, meta)", () => {
519
541
  const fx = createFx({ flow: "x", effects: {} });
package/src/kernel/fx.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * for deterministic tests (§7.6).
10
10
  */
11
11
 
12
- import type { Effects, ResourceRef } from "../manifest/types.ts";
12
+ import type { Effects, ResourceRef, SignalResourceRef } from "../manifest/types.ts";
13
+ import { isMcpToolRef } from "../manifest/mcp-ref.ts";
13
14
  import type { QueryPageSpec } from "./list-page.ts";
14
15
  import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
15
16
  import type {
@@ -27,8 +28,8 @@ import type {
27
28
  SqlStoreHandle,
28
29
  } from "../elements/store.ts";
29
30
  import type { SqlRow } from "../drivers/types.ts";
30
- import type { SignalRuntime } from "../elements/signal.ts";
31
- import type { SignalEmitOptions } from "../drivers/signal-types.ts";
31
+ import type { SignalDecl, SignalRuntime } from "../elements/signal.ts";
32
+ import type { DeadLetter, SignalEmitOptions } from "../drivers/signal-types.ts";
32
33
  import type { VaultActor, VaultAdapter, VaultRuntime } from "../elements/vault.ts";
33
34
  import type { ChannelRuntime } from "../elements/channel.ts";
34
35
  import type { AiRuntime } from "../elements/ai.ts";
@@ -73,6 +74,15 @@ async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
73
74
  /** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
74
75
  export const RUNS_RESOURCE = "runs";
75
76
 
77
+ /**
78
+ * Capability ref for {@link Fx.deadLetters} — `signal:<name>`, never a store facet.
79
+ *
80
+ * @param name - Signal name
81
+ */
82
+ export function signalReadRef(name: string): SignalResourceRef {
83
+ return `signal:${name}`;
84
+ }
85
+
76
86
  export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
77
87
 
78
88
  /** Named ref: plain string or `{ name }` element handle (optional pin). */
@@ -140,6 +150,27 @@ export interface FxTenant {
140
150
  export interface FxClock {
141
151
  /** Current epoch-ms (injectable via {@link CreateFxOptions.now}). */
142
152
  now(): number;
153
+ /**
154
+ * Instant `duration` before {@link FxClock.now} (`"30d"` → now − 30 days).
155
+ * Uses the same duration strings as {@link FxClock.sleep} / `every()`.
156
+ *
157
+ * @param duration - Duration string (e.g. `"30d"`, `"2h"`)
158
+ */
159
+ ago(duration: string): number;
160
+ /**
161
+ * Instant `duration` after {@link FxClock.now} (`"14d"` → now + 14 days).
162
+ * Uses the same duration strings as {@link FxClock.sleep} / `every()`.
163
+ *
164
+ * @param duration - Duration string (e.g. `"14d"`, `"15m"`)
165
+ */
166
+ fromNow(duration: string): number;
167
+ /**
168
+ * Span in milliseconds (`"30d"` → `2_592_000_000`). Compose with a stored
169
+ * instant: `createdAt + fx.clock.duration("7d")`. Unknown strings are `0`.
170
+ *
171
+ * @param duration - Duration string (e.g. `"30d"`, `"200ms"`)
172
+ */
173
+ duration(duration: string): number;
143
174
  /**
144
175
  * Durable sleep — when the flow is `durable`, journals the wake time and
145
176
  * survives restart / deploy. Without a journal, resolves immediately
@@ -323,12 +354,36 @@ export interface JsonResult<T = unknown> {
323
354
  readonly status: number;
324
355
  readonly value?: T;
325
356
  readonly meta?: Record<string, unknown>;
357
+ readonly kind?: undefined;
358
+ }
359
+
360
+ /** SSE carrier from {@link FxJson.stream}. */
361
+ export interface JsonStreamResult {
362
+ readonly [jsonResultBrand]: true;
363
+ readonly kind: "stream";
364
+ readonly status: 200;
365
+ readonly chunks: AsyncIterable<string>;
366
+ /** Set by the kernel to commit journal / Runs after the stream settles. */
367
+ finalize?: () => Promise<void>;
326
368
  }
327
369
 
328
- /** True when `value` is an {@link FxJson} carrier. */
370
+ /** True when `value` is an {@link FxJson} JSON-envelope carrier. */
329
371
  export function isJsonResult(value: unknown): value is JsonResult {
330
372
  return (
331
- typeof value === "object" && value !== null && (value as JsonResult)[jsonResultBrand] === true
373
+ typeof value === "object" &&
374
+ value !== null &&
375
+ (value as JsonResult)[jsonResultBrand] === true &&
376
+ (value as JsonStreamResult).kind !== "stream"
377
+ );
378
+ }
379
+
380
+ /** True when `value` is an SSE stream carrier from {@link FxJson.stream}. */
381
+ export function isJsonStreamResult(value: unknown): value is JsonStreamResult {
382
+ return (
383
+ typeof value === "object" &&
384
+ value !== null &&
385
+ (value as JsonStreamResult)[jsonResultBrand] === true &&
386
+ (value as JsonStreamResult).kind === "stream"
332
387
  );
333
388
  }
334
389
 
@@ -363,6 +418,11 @@ export interface FxJson {
363
418
  * every string field; extra keys auto-eq except `id`.
364
419
  */
365
420
  withQuery<T>(rows: readonly T[], input: unknown, spec?: QueryPageSpec<T>): JsonResult<T[]>;
421
+ /**
422
+ * 200 — `text/event-stream` of JSON `data:` frames, then `data: [DONE]`.
423
+ * Pass {@link Fx.stream} or any async iterable of token strings.
424
+ */
425
+ stream(chunks: AsyncIterable<string>): JsonStreamResult;
366
426
  }
367
427
 
368
428
  /**
@@ -567,6 +627,15 @@ export interface Fx {
567
627
  * @param options - Optional emit options (`key` for per-key once ordering)
568
628
  */
569
629
  emit(signal: NamedRef, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
630
+ /**
631
+ * Query dead-lettered messages for one signal (records `read` on `signal:<name>`).
632
+ *
633
+ * Requires a bound signal runtime and `effects.reads` including `signal:<name>`.
634
+ *
635
+ * @param signal - Signal name or handle
636
+ */
637
+ deadLetters<T>(signal: SignalDecl<T>): Promise<readonly DeadLetter<T>[]>;
638
+ deadLetters(signal: NamedRef): Promise<readonly DeadLetter[]>;
570
639
  /**
571
640
  * Call another flow (records `call`). Stub returns `undefined`.
572
641
  *
@@ -651,7 +720,11 @@ export interface Fx {
651
720
  */
652
721
  stream(
653
722
  model: NamedRef,
654
- opts?: { readonly prompt?: string; readonly data?: unknown },
723
+ opts?: {
724
+ readonly prompt?: string;
725
+ readonly data?: unknown;
726
+ readonly via?: readonly NamedRef[];
727
+ },
655
728
  ): AsyncIterable<string>;
656
729
  /** Logger. */
657
730
  readonly log: FxLog;
@@ -1336,6 +1409,15 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1336
1409
 
1337
1410
  const clock: FxClock = {
1338
1411
  now,
1412
+ ago(duration: string): number {
1413
+ return now() - parseDurationMs(duration);
1414
+ },
1415
+ fromNow(duration: string): number {
1416
+ return now() + parseDurationMs(duration);
1417
+ },
1418
+ duration(duration: string): number {
1419
+ return parseDurationMs(duration);
1420
+ },
1339
1421
  async sleep(label: string, duration: string): Promise<void> {
1340
1422
  if (journal) {
1341
1423
  await journal.sleep(label, duration, parseDurationMs);
@@ -1591,9 +1673,24 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1591
1673
  }
1592
1674
  });
1593
1675
  },
1676
+ deadLetters(signal: NamedRef) {
1677
+ const name = resolveName(signal);
1678
+ return gated("read", signalReadRef(name), async () => {
1679
+ if (!options.signalRuntime) {
1680
+ throw new Error("fx.deadLetters requires a bound signal runtime");
1681
+ }
1682
+ return options.signalRuntime.deadLetters(name);
1683
+ });
1684
+ },
1594
1685
  call(flow, input) {
1595
1686
  const name = resolveName(flow);
1596
1687
  return gated("call", name, async () => {
1688
+ if (isMcpToolRef(name)) {
1689
+ if (!options.aiRuntime) {
1690
+ throw new Error(`fx.call: AI runtime is not configured for MCP tool "${name}"`);
1691
+ }
1692
+ return options.aiRuntime.callMcp(name, input, currentAbortSignal());
1693
+ }
1597
1694
  if (options.callHandler) {
1598
1695
  return options.callHandler(name, input);
1599
1696
  }
@@ -1785,6 +1882,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1785
1882
  prompt: opts?.prompt,
1786
1883
  data: opts?.data,
1787
1884
  signal: local.signal,
1885
+ via: opts?.via?.map(resolveName),
1788
1886
  })) {
1789
1887
  if (local.signal.aborted) break;
1790
1888
  yield text;
@@ -1832,6 +1930,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1832
1930
  },
1833
1931
  with: jsonWith,
1834
1932
  withQuery: jsonWithQuery,
1933
+ stream(chunks) {
1934
+ return {
1935
+ [jsonResultBrand]: true,
1936
+ kind: "stream" as const,
1937
+ status: 200 as const,
1938
+ chunks,
1939
+ };
1940
+ },
1835
1941
  },
1836
1942
  async step<T>(name: string, fn: () => T | Promise<T>, opts?: StepOptions<T>): Promise<T> {
1837
1943
  if (journal) {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * HTTP SSE via `fx.json.stream` + client-disconnect abort of the provider fetch.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { ai } from "../elements/ai.ts";
7
+ import type { AiDriver } from "../drivers/ai-types.ts";
8
+ import { createRunsRuntime } from "../runs/runtime.ts";
9
+ import { createBunRuntime } from "../runtime/bun.ts";
10
+ import type { ServerHandle } from "../runtime/types.ts";
11
+ import { oke } from "./app.ts";
12
+ import { flow, resetFlowSeq } from "./flow.ts";
13
+ import { on, resetBindings } from "./on.ts";
14
+ import { http } from "./triggers.ts";
15
+
16
+ beforeEach(() => {
17
+ resetBindings();
18
+ resetFlowSeq();
19
+ });
20
+
21
+ function tokenDriver(chunks: readonly string[], delayMs = 0): AiDriver {
22
+ return {
23
+ id: "mock",
24
+ async open() {
25
+ return {
26
+ driverId: "mock" as const,
27
+ model: "smart",
28
+ async complete() {
29
+ return { text: "", model: "smart", driverId: "mock" as const };
30
+ },
31
+ async *stream() {
32
+ for (const text of chunks) {
33
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
34
+ yield { text };
35
+ }
36
+ },
37
+ };
38
+ },
39
+ };
40
+ }
41
+
42
+ function hangingDriver(onAbort: () => void): AiDriver {
43
+ return {
44
+ id: "mock",
45
+ async open() {
46
+ return {
47
+ driverId: "mock" as const,
48
+ model: "smart",
49
+ async complete() {
50
+ return { text: "", model: "smart", driverId: "mock" as const };
51
+ },
52
+ async *stream(opts) {
53
+ yield { text: "start" };
54
+ await new Promise<void>((resolve, reject) => {
55
+ const t = setTimeout(resolve, 8_000);
56
+ opts.signal?.addEventListener(
57
+ "abort",
58
+ () => {
59
+ onAbort();
60
+ clearTimeout(t);
61
+ const err = new Error("aborted");
62
+ err.name = "AbortError";
63
+ reject(err);
64
+ },
65
+ { once: true },
66
+ );
67
+ });
68
+ yield { text: "never" };
69
+ },
70
+ };
71
+ },
72
+ };
73
+ }
74
+
75
+ describe("fx.json.stream HTTP SSE", () => {
76
+ let handle: ServerHandle | undefined;
77
+
78
+ afterEach(() => {
79
+ handle?.stop(true);
80
+ handle = undefined;
81
+ });
82
+
83
+ test("encodes token chunks as SSE and leaves JSON flows buffered", async () => {
84
+ const smart = ai.model("smart");
85
+ on(
86
+ http.get("/chat").gate.public,
87
+ flow("chat.stream", {
88
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "hi" })),
89
+ }),
90
+ );
91
+ on(http.get("/ping").gate.public, flow("ping", { do: () => ({ ok: true as const }) }));
92
+
93
+ const app = oke({
94
+ name: "http-stream",
95
+ ai: { models: [smart], defaultDriver: tokenDriver(["Hel", "lo"]) },
96
+ });
97
+
98
+ const streamed = await app.fetch(new Request("http://localhost/chat"));
99
+ expect(streamed.headers.get("content-type")).toMatch(/text\/event-stream/);
100
+ const body = await streamed.text();
101
+ expect(body).toContain('data: "Hel"');
102
+ expect(body).toContain('data: "lo"');
103
+ expect(body).toContain("data: [DONE]");
104
+
105
+ const json = await app.fetch(new Request("http://localhost/ping"));
106
+ expect(json.headers.get("content-type")).toMatch(/application\/json/);
107
+ expect(await json.json()).toEqual({ data: { ok: true }, error: null });
108
+ });
109
+
110
+ test("client abort mid-stream aborts the upstream provider fetch", async () => {
111
+ let providerAborted = false;
112
+ const smart = ai.model("smart");
113
+ on(
114
+ http.get("/slow").gate.public,
115
+ flow("chat.slow", {
116
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "long" })),
117
+ }),
118
+ );
119
+
120
+ const app = oke({
121
+ name: "http-stream-abort",
122
+ ai: { models: [smart], defaultDriver: hangingDriver(() => (providerAborted = true)) },
123
+ });
124
+
125
+ handle = createBunRuntime().serve(app, { port: 0, hostname: "127.0.0.1" });
126
+ const ac = new AbortController();
127
+ const res = await fetch(`http://127.0.0.1:${handle.port}/slow`, { signal: ac.signal });
128
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
129
+ const reader = res.body!.getReader();
130
+ const first = await reader.read();
131
+ expect(first.done).toBe(false);
132
+ ac.abort();
133
+ await new Promise((r) => setTimeout(r, 80));
134
+ expect(providerAborted).toBe(true);
135
+ });
136
+
137
+ test("Traces stay empty until stream close; duration covers the open stream", async () => {
138
+ const runs = createRunsRuntime({ driver: "memory" });
139
+ await runs.open();
140
+ const smart = ai.model("smart");
141
+ on(
142
+ http.get("/chat").gate.public,
143
+ flow("chat.stream", {
144
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "hi" })),
145
+ }),
146
+ );
147
+
148
+ const app = oke({
149
+ name: "http-stream-traces",
150
+ ai: { models: [smart], defaultDriver: tokenDriver(["Hel", "lo"], 25) },
151
+ runs,
152
+ env: "test",
153
+ });
154
+ await app.boot({ env: "test", runs, startScheduler: false });
155
+
156
+ const streamed = await app.fetch(new Request("http://localhost/chat"));
157
+ expect(streamed.headers.get("content-type")).toMatch(/text\/event-stream/);
158
+ await runs.flush();
159
+ expect(await runs.all()).toHaveLength(0);
160
+
161
+ const body = await streamed.text();
162
+ expect(body).toContain("data: [DONE]");
163
+
164
+ await runs.flush();
165
+ const events = await runs.all();
166
+ expect(events).toHaveLength(1);
167
+ expect(events[0]!.error ?? null).toBeNull();
168
+ expect(events[0]!.output).toEqual({ streamed: true });
169
+ expect(events[0]!.durationMs).toBeGreaterThanOrEqual(40);
170
+
171
+ await app.stop();
172
+ await runs.close();
173
+ });
174
+ });
@@ -137,6 +137,7 @@ export {
137
137
  createFxContext,
138
138
  freezePrincipal,
139
139
  isJsonResult,
140
+ isJsonStreamResult,
140
141
  jsonResultBrand,
141
142
  resolveName,
142
143
  resolveStoreRef,
@@ -160,6 +161,7 @@ export {
160
161
  type FxThunk,
161
162
  type JsonPage,
162
163
  type JsonResult,
164
+ type JsonStreamResult,
163
165
  type NamedRef,
164
166
  type StepOptions,
165
167
  } from "./fx.ts";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * MCP tool resource refs — `mcp:<server>/<tool>` (capability / Manifest)
3
+ * and `<server>__<tool>` (provider-safe model tool name).
4
+ */
5
+
6
+ /** Capability / Manifest ref for one declared MCP tool. */
7
+ export type McpToolRef = `mcp:${string}/${string}`;
8
+
9
+ /** Parsed `mcp:<server>/<tool>` ref. */
10
+ export interface ParsedMcpToolRef {
11
+ readonly server: string;
12
+ readonly tool: string;
13
+ }
14
+
15
+ /**
16
+ * Build a capability ref.
17
+ *
18
+ * @param server - Declared MCP server name
19
+ * @param tool - Allowlisted tool name on that server
20
+ */
21
+ export function mcpToolRef(server: string, tool: string): McpToolRef {
22
+ return `mcp:${server}/${tool}`;
23
+ }
24
+
25
+ /**
26
+ * Parse `mcp:<server>/<tool>`. Null when the prefix or parts are missing.
27
+ *
28
+ * @param ref - Effect / tool resource string
29
+ */
30
+ export function parseMcpToolRef(ref: string): ParsedMcpToolRef | null {
31
+ if (!ref.startsWith("mcp:")) return null;
32
+ const rest = ref.slice(4);
33
+ const slash = rest.indexOf("/");
34
+ if (slash <= 0 || slash === rest.length - 1) return null;
35
+ return { server: rest.slice(0, slash), tool: rest.slice(slash + 1) };
36
+ }
37
+
38
+ /**
39
+ * True when `ref` is a well-formed MCP capability ref.
40
+ *
41
+ * @param ref - Candidate
42
+ */
43
+ export function isMcpToolRef(ref: string): boolean {
44
+ return parseMcpToolRef(ref) !== null;
45
+ }
46
+
47
+ /**
48
+ * Graph node id for one MCP server (`mcp:github`).
49
+ *
50
+ * @param server - Declared server name
51
+ */
52
+ export function mcpServerNodeId(server: string): string {
53
+ return `mcp:${server}`;
54
+ }
55
+
56
+ /**
57
+ * Provider-safe function name (`github__create_issue`).
58
+ *
59
+ * @param server - Declared server name
60
+ * @param tool - MCP tool name
61
+ */
62
+ export function mcpModelToolName(server: string, tool: string): string {
63
+ return `${server}__${tool}`;
64
+ }
65
+
66
+ /**
67
+ * Inverse of {@link mcpModelToolName}.
68
+ *
69
+ * @param name - Model-facing tool name
70
+ */
71
+ export function parseMcpModelToolName(name: string): ParsedMcpToolRef | null {
72
+ const sep = name.indexOf("__");
73
+ if (sep <= 0 || sep === name.length - 2) return null;
74
+ return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
75
+ }
76
+
77
+ /**
78
+ * Capability ref from either `mcp:server/tool` or `server__tool`.
79
+ *
80
+ * @param name - Model-facing or capability name
81
+ */
82
+ export function mcpCapabilityRefFromName(name: string): McpToolRef | null {
83
+ const fromRef = parseMcpToolRef(name);
84
+ if (fromRef) return mcpToolRef(fromRef.server, fromRef.tool);
85
+ const fromModel = parseMcpModelToolName(name);
86
+ if (fromModel) return mcpToolRef(fromModel.server, fromModel.tool);
87
+ return null;
88
+ }
@@ -46,6 +46,12 @@ export type ResourceRef = `${StoreFacet}:${string}`;
46
46
  */
47
47
  export type RunsResourceRef = "runs";
48
48
 
49
+ /**
50
+ * Dead-letter read capability for `fx.deadLetters` — not a store facet.
51
+ * Declare on `effects.reads` (never `writes`).
52
+ */
53
+ export type SignalResourceRef = `signal:${string}`;
54
+
49
55
  /** Signal name reference. */
50
56
  export type SignalRef = string;
51
57
 
@@ -74,8 +80,8 @@ export type JsonSchema = string | Record<string, unknown>;
74
80
  * `sends` / `asks` are irreversible (asks also nondeterministic + cost).
75
81
  */
76
82
  export interface Effects {
77
- /** Store reads, plus optional `"runs"` for `fx.runs`. */
78
- reads?: Array<ResourceRef | RunsResourceRef>;
83
+ /** Store reads, plus `"runs"` for `fx.runs` and `signal:name` for `fx.deadLetters`. */
84
+ reads?: Array<ResourceRef | RunsResourceRef | SignalResourceRef>;
79
85
  /** Store writes. */
80
86
  writes?: ResourceRef[];
81
87
  /** Emitted signals. */
@@ -297,6 +303,8 @@ export interface AiModel {
297
303
  provider?: string;
298
304
  tier?: string;
299
305
  model?: string;
306
+ /** Protocol driver override for this logical binding. */
307
+ driverId?: string;
300
308
  }
301
309
 
302
310
  /** Prompt / agent budget. */
@@ -327,11 +335,29 @@ export interface AiAgent {
327
335
  budget?: AiBudget;
328
336
  }
329
337
 
338
+ /**
339
+ * External MCP server the app consumes — tools join the existing `fx.call`
340
+ * / `toolLoop` path as `mcp:<server>/<tool>` capability refs.
341
+ */
342
+ export interface AiMcpServer {
343
+ /** Streamable HTTP endpoint. */
344
+ url?: string;
345
+ /** stdio executable (no shell string). */
346
+ command?: string;
347
+ /** Arguments for {@link command}. */
348
+ args?: string[];
349
+ /** Bearer secret contract name (never the value). */
350
+ auth?: SecretRef;
351
+ /** Required allowlist — never trust whatever `tools/list` exposes. */
352
+ tools: string[];
353
+ }
354
+
330
355
  /** AI element catalogue. */
331
356
  export interface Ai {
332
357
  models?: Record<string, AiModel>;
333
358
  prompts?: Record<string, AiPrompt>;
334
359
  agents?: Record<string, AiAgent>;
360
+ mcpServers?: Record<string, AiMcpServer>;
335
361
  }
336
362
 
337
363
  /** Per-table metadata on a Manifest {@link Plugin}. */
@@ -75,6 +75,26 @@ describe("validateManifest", () => {
75
75
  expect(result.ok).toBe(false);
76
76
  });
77
77
 
78
+ test("accepts signal resource refs on reads only", async () => {
79
+ const reads = await validateManifest({
80
+ oke: "1.0",
81
+ app: "x",
82
+ flows: {
83
+ f: { effects: { reads: ["signal:notify"] } },
84
+ },
85
+ });
86
+ expect(reads.ok).toBe(true);
87
+
88
+ const writes = await validateManifest({
89
+ oke: "1.0",
90
+ app: "x",
91
+ flows: {
92
+ f: { effects: { writes: ["signal:notify"] } },
93
+ },
94
+ });
95
+ expect(writes.ok).toBe(false);
96
+ });
97
+
78
98
  test("parseManifest throws ManifestValidationError on invalid JSON", async () => {
79
99
  expect(parseManifest("{")).rejects.toBeInstanceOf(ManifestValidationError);
80
100
  });
@@ -197,7 +197,7 @@ export async function serveDocsMcp(
197
197
  hostname: boundHost,
198
198
  fetch: mcp.fetch,
199
199
  stop(closeActive = false) {
200
- server.stop(closeActive);
200
+ return server.stop(closeActive);
201
201
  },
202
202
  };
203
203
  }
package/src/mcp/server.ts CHANGED
@@ -268,7 +268,7 @@ export async function serveMcp(options: ServeMcpOptions): Promise<McpServerHandl
268
268
  hostname: boundHost,
269
269
  fetch: mcp.fetch,
270
270
  stop(closeActive = false) {
271
- server.stop(closeActive);
271
+ return server.stop(closeActive);
272
272
  },
273
273
  };
274
274
  }
@@ -122,4 +122,25 @@ describe("compression plugin", () => {
122
122
  );
123
123
  expect(nt.headers.get("content-encoding")).toBeNull();
124
124
  });
125
+
126
+ test("skips text/event-stream even when Accept-Encoding is gzip", async () => {
127
+ on(
128
+ http.get("/sse"),
129
+ flow("sse.get", {
130
+ do: (_input, fx) =>
131
+ fx.json.stream(
132
+ (async function* () {
133
+ yield "hello";
134
+ })(),
135
+ ),
136
+ }),
137
+ );
138
+ const app = oke({ autoBoot: false, name: "zip-sse" }).plug(compression({ minSize: 0 }));
139
+ const res = await app.fetch(
140
+ new Request("http://localhost/sse", { headers: { "accept-encoding": "gzip" } }),
141
+ );
142
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
143
+ expect(res.headers.get("content-encoding")).toBeNull();
144
+ expect(await res.text()).toContain('data: "hello"');
145
+ });
125
146
  });
@@ -73,6 +73,7 @@ export function compression(
73
73
  if (cacheControl !== null && /\bno-transform\b/i.test(cacheControl)) return;
74
74
 
75
75
  const contentType = headers.get("content-type") ?? "";
76
+ if (/^text\/event-stream\b/i.test(contentType.split(";")[0]?.trim() ?? "")) return;
76
77
  if (!match.test(contentType)) return;
77
78
 
78
79
  const body = await ctx.response.arrayBuffer();