@gnldev/chat-adapter 0.1.0

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.
@@ -0,0 +1,39 @@
1
+ // P0.2 the three INTERNAL sentinels durable-tool.ts returns as a tool's `output`
2
+ // (see packages/durable/src/durable-tool.ts + packages/server/src/sse.ts's tool-result handling, which
3
+ // this masking is kept IN SYNC with) must NEVER reach a browser useChat client verbatim — they carry
4
+ // internal fields (`detail`, raw guard reasons) that are an INTERNAL API, not a wire contract. This one
5
+ // helper is shared by ui-stream.ts (LIVE stream masking) and messages.ts (HISTORY reconstruction) so the
6
+ // masked shape can't drift between the two call sites.
7
+ import { surfacedInterrupts } from '@gnldev/durable';
8
+ /**
9
+ * Masks a tool-result `output` value if (and only if) it carries one of the three sentinels; passes
10
+ * everything else through UNCHANGED. `toolNameHint` backfills `toolName` for a suspend sentinel that
11
+ * (in older journal records) might not carry its own `toolName` field.
12
+ */
13
+ export function maskSentinelOutput(output, toolNameHint) {
14
+ const o = output;
15
+ if (o && typeof o === 'object') {
16
+ if (o.__gnl_suspend) {
17
+ const s = o.__gnl_suspend;
18
+ // `display` still describes THIS chunk — its toolCallId is the suspended tool's, so the name and
19
+ // reason shown next to it are that tool's, proxy or not. Only the answerable ids are unwrapped,
20
+ // and by the engine's own function rather than a fourth private copy of the rule.
21
+ const interrupts = surfacedInterrupts(s);
22
+ return {
23
+ display: { pending: 'approval', toolName: s.toolName ?? toolNameHint, reason: s.reason },
24
+ interrupts,
25
+ ...(interrupts[0] ? { interrupt: interrupts[0] } : {}), // deprecated single — see MaskedToolOutput
26
+ };
27
+ }
28
+ if (o.__gnl_limit_exceeded) {
29
+ const s = o.__gnl_limit_exceeded;
30
+ return { display: { blocked: true, code: s.kind, message: s.message } };
31
+ }
32
+ if (o.__gnl_blocked) {
33
+ const s = o.__gnl_blocked;
34
+ return { display: { blocked: true, code: s.code, message: s.message } };
35
+ }
36
+ }
37
+ return { display: output };
38
+ }
39
+ //# sourceMappingURL=sentinel-mask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sentinel-mask.js","sourceRoot":"","sources":["../src/sentinel-mask.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,uGAAuG;AACvG,qGAAqG;AACrG,wGAAwG;AACxG,yGAAyG;AACzG,uDAAuD;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AA+BrD;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAe,EAAE,YAAqB;IACvE,MAAM,CAAC,GAAG,MAAoD,CAAC;IAC/D,IAAI,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,EAAE,CAAC;QAC/B,IAAI,CAAC,CAAC,aAAa,EAAE,CAAC;YACpB,MAAM,CAAC,GAAG,CAAC,CAAC,aAA0B,CAAC;YACvC,iGAAiG;YACjG,gGAAgG;YAChG,kFAAkF;YAClF,MAAM,UAAU,GAAG,kBAAkB,CAAC,CAAC,CAAC,CAAC;YACzC,OAAO;gBACL,OAAO,EAAE,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,YAAY,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;gBACxF,UAAU;gBACV,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,2CAA2C;aACpG,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,CAAC,oBAAoB,EAAE,CAAC;YAC3B,MAAM,CAAC,GAAG,CAAC,CAAC,oBAAyD,CAAC;YACtE,OAAO,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAC1E,CAAC;QACD,IAAI,CAAC,CAAC,aAAa,EAAE,CAAC;YACpB,MAAM,CAAC,GAAG,CAAC,CAAC,aAAkD,CAAC;YAC/D,OAAO,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAC1E,CAAC;IACH,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;AAC7B,CAAC","sourcesContent":["// P0.2 the three INTERNAL sentinels durable-tool.ts returns as a tool's `output`\n// (see packages/durable/src/durable-tool.ts + packages/server/src/sse.ts's tool-result handling, which\n// this masking is kept IN SYNC with) must NEVER reach a browser useChat client verbatim — they carry\n// internal fields (`detail`, raw guard reasons) that are an INTERNAL API, not a wire contract. This one\n// helper is shared by ui-stream.ts (LIVE stream masking) and messages.ts (HISTORY reconstruction) so the\n// masked shape can't drift between the two call sites.\nimport { surfacedInterrupts } from '@gnldev/durable';\nimport type { Interrupt } from '@gnldev/durable';\n\nexport interface MaskedToolOutput {\n /** What the client should see in place of the raw tool output — either the untouched original value,\n * Or a masked `{ pending: 'approval', ... }` / `{ blocked: true, ... }` replacement. */\n display: unknown;\n /**\n * Present only when `display` masked a `__gnl_suspend` sentinel — the question(s) a human can\n * actually answer, for the caller to surface via a `data-gnl-interrupt` chunk (ui-stream.ts) or\n * equivalent.\n *\n * A LIST, and the singular it replaces was not a style choice — it was structurally short. When a\n * delegated sub-agent hits a human gate, the PARENT's record suspends too, and that sentinel is\n * necessarily keyed by the parent's toolCallId: a proxy id with no question behind it. The engine\n * answers this in one place (`surfacedInterrupts`) by surfacing the CHILD's interrupts instead —\n * and a child run can be sitting on more than one. Handing back the first and dropping the rest\n * would leave a client that approved everything it was shown still suspended.\n *\n * Ordinary suspends are unaffected: one interrupt in, a one-element array out, same fields.\n */\n interrupts?: Interrupt[];\n /**\n * @deprecated Use `interrupts`. Kept because this field shipped, and filled with `interrupts[0]`\n * so existing readers keep working — but it CANNOT represent a nested suspend carrying more than\n * one child question, which is the reason `interrupts` exists. Reading it is reading the first\n * question and silently ignoring the others.\n */\n interrupt?: Interrupt;\n}\n\n/**\n * Masks a tool-result `output` value if (and only if) it carries one of the three sentinels; passes\n * everything else through UNCHANGED. `toolNameHint` backfills `toolName` for a suspend sentinel that\n * (in older journal records) might not carry its own `toolName` field.\n */\nexport function maskSentinelOutput(output: unknown, toolNameHint?: string): MaskedToolOutput {\n const o = output as Record<string, unknown> | null | undefined;\n if (o && typeof o === 'object') {\n if (o.__gnl_suspend) {\n const s = o.__gnl_suspend as Interrupt;\n // `display` still describes THIS chunk — its toolCallId is the suspended tool's, so the name and\n // reason shown next to it are that tool's, proxy or not. Only the answerable ids are unwrapped,\n // and by the engine's own function rather than a fourth private copy of the rule.\n const interrupts = surfacedInterrupts(s);\n return {\n display: { pending: 'approval', toolName: s.toolName ?? toolNameHint, reason: s.reason },\n interrupts,\n ...(interrupts[0] ? { interrupt: interrupts[0] } : {}), // deprecated single — see MaskedToolOutput\n };\n }\n if (o.__gnl_limit_exceeded) {\n const s = o.__gnl_limit_exceeded as { kind: string; message: string };\n return { display: { blocked: true, code: s.kind, message: s.message } };\n }\n if (o.__gnl_blocked) {\n const s = o.__gnl_blocked as { code: string; message: string };\n return { display: { blocked: true, code: s.code, message: s.message } };\n }\n }\n return { display: output };\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import type { AsyncIterableStream, StreamTextResult, UIMessage, UIMessageChunk, UIMessageStreamOptions } from 'ai';
2
+ /** A `data-gnl-interrupt` chunk's payload — one entry per newly-masked suspend in this transform call.
3
+ * FAZ-2: `runId` (when the caller provided one) is the approval ADDRESS — without it the client's
4
+ * "approve" re-POST derives a FRESH runId from its new message id, the approval lands on a brand-new
5
+ * run, and the suspended run stays suspended forever (retention keeps suspended runs deliberately →
6
+ * unbounded accumulation). Approve by re-POSTing with THIS runId — see `approvalPayload`/`approve`. */
7
+ export interface GnlInterruptData {
8
+ interrupts: Array<{
9
+ toolCallId: string;
10
+ toolName: string;
11
+ args: unknown;
12
+ reason?: string;
13
+ runId?: string;
14
+ }>;
15
+ }
16
+ /**
17
+ * Thin wrapper over `result.toUIMessageStream()` with the sentinel-masking transform ALWAYS applied.
18
+ * `result` is whatever `gnl.stream()`/`streamDurable` returns (a real AI SDK `StreamTextResult` under a
19
+ * method-binding Proxy — see run.ts's `guardStreamTerminalPromises`).
20
+ */
21
+ export declare function toUIMessageStream<UI_MESSAGE extends UIMessage = UIMessage>(result: Pick<StreamTextResult<any, any, any>, 'toUIMessageStream'>, opts?: UIMessageStreamOptions<UI_MESSAGE> & {
22
+ runId?: string;
23
+ }): AsyncIterableStream<UIMessageChunk>;
24
+ /**
25
+ * `UIMessageStreamResponseInit` (the native method's other option half) is NOT exported by the `ai`
26
+ * package (private type) — this mirrors its structural shape (verified against
27
+ * `toUIMessageStreamResponse`'s declared parameter in the INSTALLED ai major's .d.ts; originally on
28
+ * ai@5, re-checked on the ai@7 upgrade) without importing a name that isn't part of the package's
29
+ * public surface.
30
+ */
31
+ export interface ToUIMessageStreamResponseOptions<UI_MESSAGE extends UIMessage = UIMessage> extends UIMessageStreamOptions<UI_MESSAGE>, ResponseInit {
32
+ consumeSseStream?: (options: {
33
+ stream: ReadableStream<string>;
34
+ }) => PromiseLike<void> | void;
35
+ /** FAZ-2: stamped onto `data-gnl-interrupt` chunks as the approval address (see GnlInterruptData). */
36
+ runId?: string;
37
+ }
38
+ /**
39
+ * Thin wrapper over `createUIMessageStreamResponse` — ALWAYS runs the masking transform (see the module
40
+ * header note: calling the native `result.toUIMessageStreamResponse()` directly is deliberately never
41
+ * done here, since that would bypass masking).
42
+ */
43
+ export declare function toUIMessageStreamResponse<UI_MESSAGE extends UIMessage = UIMessage>(result: Pick<StreamTextResult<any, any, any>, 'toUIMessageStream'>, opts?: ToUIMessageStreamResponseOptions<UI_MESSAGE>): Response;
@@ -0,0 +1,85 @@
1
+ // UseChat (AI SDK v5) wire format: a StreamTextResult's `toUIMessageStream()`/`toUIMessageStreamResponse()`
2
+ // already exist on the object streamDurable returns (packages/durable/src/run.ts's
3
+ // `guardStreamTerminalPromises` is a Proxy that BINDS methods off the real AI SDK StreamTextResult — it
4
+ // does not replace or hide them). This module wraps those two native entry points with a MANDATORY
5
+ // sentinel-masking transform: durable-tool.ts's three internal sentinels (`__gnl_suspend`,
6
+ // `__gnl_limit_exceeded`, `__gnl_blocked` — see packages/server/src/sse.ts's tool-result handling, which
7
+ // this is kept in sync with) show up as ordinary tool OUTPUTS in the native UI chunk stream and must
8
+ // never reach the browser verbatim (see sentinel-mask.ts for the shared masking shape).
9
+ //
10
+ // P0.2 `toUIMessageStreamResponse` here NEVER calls the native
11
+ // `result.toUIMessageStreamResponse()` directly — that would skip the masking transform entirely. It
12
+ // always builds the masked chunk stream first, then wraps it with `createUIMessageStreamResponse`.
13
+ //
14
+ // Chunk type names below (`tool-input-start`, `tool-input-available`, `tool-output-available`,
15
+ // `data-${string}`) are verified against the INSTALLED `ai` major's `UIMessageChunk` definition —
16
+ // tsc compiles this file against that .d.ts, so a renamed chunk type fails the build rather than
17
+ // silently passing through unmasked. (Originally verified on ai@5; re-verified on the ai@7 upgrade.)
18
+ import { createUIMessageStreamResponse } from 'ai';
19
+ import { maskSentinelOutput } from './sentinel-mask.js';
20
+ /**
21
+ * Wraps a native `UIMessageChunk` stream with sentinel masking:
22
+ * a `tool-output-available` chunk whose `output` carries `__gnl_suspend` → the chunk's `output` is
23
+ * Replaced with `{ pending: 'approval', toolName, reason }`, and a `data-gnl-interrupt` chunk
24
+ * `{ type: 'data-gnl-interrupt', data: { interrupts: [...] } }` is appended right after it (so a
25
+ * `useChat` client can render an approval UI without inspecting tool internals).
26
+ * `__gnl_limit_exceeded` / `__gnl_blocked` outputs → replaced with `{ blocked: true, code, message }`
27
+ * (no internal `detail`/raw fields leak).
28
+ * everything else passes through untouched.
29
+ * `toolName` is tracked from `tool-input-start`/`tool-input-available` chunks (`tool-output-available`
30
+ * itself carries only `toolCallId`, not `toolName` — see the UIMessageChunk union in ai@5's .d.ts) so the
31
+ * masked suspend payload can still report which tool is awaiting approval.
32
+ */
33
+ function maskSentinelChunks(runId) {
34
+ const toolNames = new Map();
35
+ return new TransformStream({
36
+ transform(chunk, controller) {
37
+ if ((chunk.type === 'tool-input-start' || chunk.type === 'tool-input-available' || chunk.type === 'tool-input-error') &&
38
+ 'toolName' in chunk) {
39
+ toolNames.set(chunk.toolCallId, chunk.toolName);
40
+ }
41
+ if (chunk.type === 'tool-output-available') {
42
+ const { display, interrupts } = maskSentinelOutput(chunk.output, toolNames.get(chunk.toolCallId));
43
+ if (display !== chunk.output) {
44
+ controller.enqueue({ ...chunk, output: display });
45
+ if (interrupts?.length) {
46
+ // FAZ-2: stamp the run's id onto each interrupt — the client must approve THIS run, not a
47
+ // freshly-derived one (see GnlInterruptData's JSDoc). ALL of them, not just the first: one
48
+ // suspended parent can be standing in for several child questions, and the useChat channel
49
+ // was the last surface still promising exactly one (see MaskedToolOutput.interrupts).
50
+ const data = { interrupts: runId ? interrupts.map((i) => ({ ...i, runId })) : interrupts };
51
+ controller.enqueue({ type: 'data-gnl-interrupt', data });
52
+ }
53
+ return;
54
+ }
55
+ }
56
+ controller.enqueue(chunk);
57
+ },
58
+ });
59
+ }
60
+ /**
61
+ * Thin wrapper over `result.toUIMessageStream()` with the sentinel-masking transform ALWAYS applied.
62
+ * `result` is whatever `gnl.stream()`/`streamDurable` returns (a real AI SDK `StreamTextResult` under a
63
+ * method-binding Proxy — see run.ts's `guardStreamTerminalPromises`).
64
+ */
65
+ export function toUIMessageStream(result, opts) {
66
+ // FAZ-2: `runId` is OURS (stamped onto interrupt chunks), not the AI SDK's — split it off before
67
+ // handing the rest to the native stream builder.
68
+ const { runId, ...native } = opts ?? {};
69
+ const nativeStream = result.toUIMessageStream(native);
70
+ // PipeThrough on a WHATWG ReadableStream keeps async-iterability (verified: Node's native
71
+ // ReadableStream implements Symbol.asyncIterator; ai's own AsyncIterableStream helper relies on the
72
+ // same `pipeThrough(new TransformStream())` pattern — see ai/dist/index.mjs's createAsyncIterableStream).
73
+ return nativeStream.pipeThrough(maskSentinelChunks(runId));
74
+ }
75
+ /**
76
+ * Thin wrapper over `createUIMessageStreamResponse` — ALWAYS runs the masking transform (see the module
77
+ * header note: calling the native `result.toUIMessageStreamResponse()` directly is deliberately never
78
+ * done here, since that would bypass masking).
79
+ */
80
+ export function toUIMessageStreamResponse(result, opts) {
81
+ const { status, statusText, headers, consumeSseStream, ...streamOpts } = opts ?? {};
82
+ const stream = toUIMessageStream(result, streamOpts);
83
+ return createUIMessageStreamResponse({ status, statusText, headers, consumeSseStream, stream });
84
+ }
85
+ //# sourceMappingURL=ui-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-stream.js","sourceRoot":"","sources":["../src/ui-stream.ts"],"names":[],"mappings":"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","sourcesContent":["// UseChat (AI SDK v5) wire format: a StreamTextResult's `toUIMessageStream()`/`toUIMessageStreamResponse()`\n// already exist on the object streamDurable returns (packages/durable/src/run.ts's\n// `guardStreamTerminalPromises` is a Proxy that BINDS methods off the real AI SDK StreamTextResult — it\n// does not replace or hide them). This module wraps those two native entry points with a MANDATORY\n// sentinel-masking transform: durable-tool.ts's three internal sentinels (`__gnl_suspend`,\n// `__gnl_limit_exceeded`, `__gnl_blocked` — see packages/server/src/sse.ts's tool-result handling, which\n// this is kept in sync with) show up as ordinary tool OUTPUTS in the native UI chunk stream and must\n// never reach the browser verbatim (see sentinel-mask.ts for the shared masking shape).\n//\n// P0.2 `toUIMessageStreamResponse` here NEVER calls the native\n// `result.toUIMessageStreamResponse()` directly — that would skip the masking transform entirely. It\n// always builds the masked chunk stream first, then wraps it with `createUIMessageStreamResponse`.\n//\n// Chunk type names below (`tool-input-start`, `tool-input-available`, `tool-output-available`,\n// `data-${string}`) are verified against the INSTALLED `ai` major's `UIMessageChunk` definition —\n// tsc compiles this file against that .d.ts, so a renamed chunk type fails the build rather than\n// silently passing through unmasked. (Originally verified on ai@5; re-verified on the ai@7 upgrade.)\nimport { createUIMessageStreamResponse } from 'ai';\nimport type { AsyncIterableStream, StreamTextResult, UIMessage, UIMessageChunk, UIMessageStreamOptions } from 'ai';\nimport { maskSentinelOutput } from './sentinel-mask.js';\n\n/** A `data-gnl-interrupt` chunk's payload — one entry per newly-masked suspend in this transform call.\n * FAZ-2: `runId` (when the caller provided one) is the approval ADDRESS — without it the client's\n * \"approve\" re-POST derives a FRESH runId from its new message id, the approval lands on a brand-new\n * run, and the suspended run stays suspended forever (retention keeps suspended runs deliberately →\n * unbounded accumulation). Approve by re-POSTing with THIS runId — see `approvalPayload`/`approve`. */\nexport interface GnlInterruptData {\n interrupts: Array<{ toolCallId: string; toolName: string; args: unknown; reason?: string; runId?: string }>;\n}\n\n/**\n * Wraps a native `UIMessageChunk` stream with sentinel masking:\n * a `tool-output-available` chunk whose `output` carries `__gnl_suspend` → the chunk's `output` is\n * Replaced with `{ pending: 'approval', toolName, reason }`, and a `data-gnl-interrupt` chunk\n * `{ type: 'data-gnl-interrupt', data: { interrupts: [...] } }` is appended right after it (so a\n * `useChat` client can render an approval UI without inspecting tool internals).\n * `__gnl_limit_exceeded` / `__gnl_blocked` outputs → replaced with `{ blocked: true, code, message }`\n * (no internal `detail`/raw fields leak).\n * everything else passes through untouched.\n * `toolName` is tracked from `tool-input-start`/`tool-input-available` chunks (`tool-output-available`\n * itself carries only `toolCallId`, not `toolName` — see the UIMessageChunk union in ai@5's .d.ts) so the\n * masked suspend payload can still report which tool is awaiting approval.\n */\nfunction maskSentinelChunks(runId?: string): TransformStream<UIMessageChunk, UIMessageChunk> {\n const toolNames = new Map<string, string>();\n return new TransformStream<UIMessageChunk, UIMessageChunk>({\n transform(chunk, controller) {\n if (\n (chunk.type === 'tool-input-start' || chunk.type === 'tool-input-available' || chunk.type === 'tool-input-error') &&\n 'toolName' in chunk\n ) {\n toolNames.set(chunk.toolCallId, chunk.toolName);\n }\n if (chunk.type === 'tool-output-available') {\n const { display, interrupts } = maskSentinelOutput(chunk.output, toolNames.get(chunk.toolCallId));\n if (display !== chunk.output) {\n controller.enqueue({ ...chunk, output: display });\n if (interrupts?.length) {\n // FAZ-2: stamp the run's id onto each interrupt — the client must approve THIS run, not a\n // freshly-derived one (see GnlInterruptData's JSDoc). ALL of them, not just the first: one\n // suspended parent can be standing in for several child questions, and the useChat channel\n // was the last surface still promising exactly one (see MaskedToolOutput.interrupts).\n const data: GnlInterruptData = { interrupts: runId ? interrupts.map((i) => ({ ...i, runId })) : interrupts };\n controller.enqueue({ type: 'data-gnl-interrupt', data } as UIMessageChunk);\n }\n return;\n }\n }\n controller.enqueue(chunk);\n },\n });\n}\n\n/**\n * Thin wrapper over `result.toUIMessageStream()` with the sentinel-masking transform ALWAYS applied.\n * `result` is whatever `gnl.stream()`/`streamDurable` returns (a real AI SDK `StreamTextResult` under a\n * method-binding Proxy — see run.ts's `guardStreamTerminalPromises`).\n */\nexport function toUIMessageStream<UI_MESSAGE extends UIMessage = UIMessage>(\n result: Pick<StreamTextResult<any, any, any>, 'toUIMessageStream'>,\n opts?: UIMessageStreamOptions<UI_MESSAGE> & { runId?: string },\n): AsyncIterableStream<UIMessageChunk> {\n // FAZ-2: `runId` is OURS (stamped onto interrupt chunks), not the AI SDK's — split it off before\n // handing the rest to the native stream builder.\n const { runId, ...native } = opts ?? {};\n const nativeStream = result.toUIMessageStream(native as UIMessageStreamOptions<UI_MESSAGE>);\n // PipeThrough on a WHATWG ReadableStream keeps async-iterability (verified: Node's native\n // ReadableStream implements Symbol.asyncIterator; ai's own AsyncIterableStream helper relies on the\n // same `pipeThrough(new TransformStream())` pattern — see ai/dist/index.mjs's createAsyncIterableStream).\n return nativeStream.pipeThrough(maskSentinelChunks(runId)) as AsyncIterableStream<UIMessageChunk>;\n}\n\n/**\n * `UIMessageStreamResponseInit` (the native method's other option half) is NOT exported by the `ai`\n * package (private type) — this mirrors its structural shape (verified against\n * `toUIMessageStreamResponse`'s declared parameter in the INSTALLED ai major's .d.ts; originally on\n * ai@5, re-checked on the ai@7 upgrade) without importing a name that isn't part of the package's\n * public surface.\n */\nexport interface ToUIMessageStreamResponseOptions<UI_MESSAGE extends UIMessage = UIMessage>\n extends UIMessageStreamOptions<UI_MESSAGE>,\n ResponseInit {\n consumeSseStream?: (options: { stream: ReadableStream<string> }) => PromiseLike<void> | void;\n /** FAZ-2: stamped onto `data-gnl-interrupt` chunks as the approval address (see GnlInterruptData). */\n runId?: string;\n}\n\n/**\n * Thin wrapper over `createUIMessageStreamResponse` — ALWAYS runs the masking transform (see the module\n * header note: calling the native `result.toUIMessageStreamResponse()` directly is deliberately never\n * done here, since that would bypass masking).\n */\nexport function toUIMessageStreamResponse<UI_MESSAGE extends UIMessage = UIMessage>(\n result: Pick<StreamTextResult<any, any, any>, 'toUIMessageStream'>,\n opts?: ToUIMessageStreamResponseOptions<UI_MESSAGE>,\n): Response {\n const { status, statusText, headers, consumeSseStream, ...streamOpts } = opts ?? {};\n const stream = toUIMessageStream(result, streamOpts as UIMessageStreamOptions<UI_MESSAGE> | undefined);\n return createUIMessageStreamResponse({ status, statusText, headers, consumeSseStream, stream });\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@gnldev/chat-adapter",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "engines": {
6
+ "node": ">=22.13.0"
7
+ },
8
+ "description": "Vercel AI SDK `useChat` compatibility for @gnldev/durable agents: UI message stream conversion (with sentinel masking), journal→UIMessage history reconstruction, and a Hono chat route.",
9
+ "keywords": [
10
+ "ai",
11
+ "agent",
12
+ "llm",
13
+ "typescript",
14
+ "ai-sdk",
15
+ "durable",
16
+ "exactly-once",
17
+ "usechat",
18
+ "ui-stream"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "dependencies": {
34
+ "@gnldev/durable": "^0.1.0"
35
+ },
36
+ "peerDependencies": {
37
+ "ai": "^7.0.0",
38
+ "hono": "^4.6.0"
39
+ },
40
+ "devDependencies": {
41
+ "ai": "^7.0.0",
42
+ "hono": "^4.13.2",
43
+ "zod": "^3.25.0",
44
+ "@gnldev/durable": "0.1.0"
45
+ },
46
+ "author": "Karaca Yılmaz (https://gnl.dev)",
47
+ "homepage": "https://gnl.dev",
48
+ "bugs": {
49
+ "url": "https://github.com/Karaca7/gnldev/issues"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/Karaca7/gnldev.git",
54
+ "directory": "packages/chat-adapter"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.json",
61
+ "typecheck": "tsc -p tsconfig.json --noEmit",
62
+ "test": "vitest run"
63
+ }
64
+ }