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.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +54 -1
- package/src/compiler/extract.ts +69 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +28 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
package/src/kernel/fx.test.ts
CHANGED
|
@@ -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" &&
|
|
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?: {
|
|
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
|
+
});
|
package/src/kernel/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/manifest/types.ts
CHANGED
|
@@ -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
|
|
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
|
});
|
package/src/mcp/docs-server.ts
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -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();
|