@ably/ai-transport 0.2.0 → 0.3.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.
Files changed (166) hide show
  1. package/README.md +10 -19
  2. package/dist/ably-ai-transport.js +1790 -1091
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +2 -2
  7. package/dist/core/agent.d.ts +20 -5
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +4 -1
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +2 -7
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -1
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  20. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  21. package/dist/core/codec/output-descriptors.d.ts +237 -0
  22. package/dist/core/codec/types.d.ts +95 -36
  23. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  24. package/dist/core/transport/agent-view.d.ts +296 -0
  25. package/dist/core/transport/decode-fold.d.ts +40 -32
  26. package/dist/core/transport/headers.d.ts +30 -1
  27. package/dist/core/transport/index.d.ts +1 -1
  28. package/dist/core/transport/invocation.d.ts +1 -1
  29. package/dist/core/transport/load-history-pages.d.ts +71 -0
  30. package/dist/core/transport/load-history.d.ts +21 -16
  31. package/dist/core/transport/run-manager.d.ts +9 -11
  32. package/dist/core/transport/session-support.d.ts +55 -0
  33. package/dist/core/transport/tree.d.ts +165 -15
  34. package/dist/core/transport/types/agent.d.ts +120 -98
  35. package/dist/core/transport/types/client.d.ts +45 -12
  36. package/dist/core/transport/types/tree.d.ts +52 -10
  37. package/dist/core/transport/types/view.d.ts +55 -28
  38. package/dist/core/transport/view.d.ts +176 -58
  39. package/dist/core/transport/wire-log.d.ts +102 -0
  40. package/dist/errors.d.ts +10 -4
  41. package/dist/index.d.ts +6 -5
  42. package/dist/react/ably-ai-transport-react.js +784 -415
  43. package/dist/react/ably-ai-transport-react.js.map +1 -1
  44. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  45. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  46. package/dist/react/contexts/client-session-context.d.ts +2 -1
  47. package/dist/react/contexts/client-session-provider.d.ts +3 -0
  48. package/dist/react/index.d.ts +2 -1
  49. package/dist/react/internal/skipped-session.d.ts +8 -0
  50. package/dist/react/use-view.d.ts +3 -3
  51. package/dist/utils.d.ts +22 -54
  52. package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
  53. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  55. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  56. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  57. package/dist/vercel/codec/events.d.ts +1 -2
  58. package/dist/vercel/codec/fields.d.ts +44 -0
  59. package/dist/vercel/codec/fold-content.d.ts +16 -0
  60. package/dist/vercel/codec/fold-data.d.ts +16 -0
  61. package/dist/vercel/codec/fold-input.d.ts +67 -0
  62. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  63. package/dist/vercel/codec/fold-text.d.ts +16 -0
  64. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  65. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  66. package/dist/vercel/codec/index.d.ts +5 -30
  67. package/dist/vercel/codec/inputs.d.ts +11 -0
  68. package/dist/vercel/codec/outputs.d.ts +11 -0
  69. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  70. package/dist/vercel/codec/reducer.d.ts +20 -102
  71. package/dist/vercel/codec/tool-transitions.d.ts +0 -6
  72. package/dist/vercel/codec/wire-data.d.ts +34 -0
  73. package/dist/vercel/index.d.ts +1 -0
  74. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
  75. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  76. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
  77. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  78. package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
  79. package/dist/vercel/run-end-reason.d.ts +66 -11
  80. package/dist/vercel/tool-part.d.ts +21 -0
  81. package/dist/vercel/transport/chat-transport.d.ts +0 -2
  82. package/dist/vercel/transport/index.d.ts +1 -1
  83. package/dist/vercel/transport/run-output-stream.d.ts +6 -8
  84. package/dist/version.d.ts +1 -1
  85. package/package.json +2 -2
  86. package/src/constants.ts +2 -2
  87. package/src/core/agent.ts +43 -19
  88. package/src/core/channel-options.ts +89 -0
  89. package/src/core/codec/codec-event.ts +27 -0
  90. package/src/core/codec/decoder.ts +145 -21
  91. package/src/core/codec/define-codec.ts +432 -0
  92. package/src/core/codec/encoder.ts +13 -54
  93. package/src/core/codec/field-bag.ts +142 -0
  94. package/src/core/codec/fields.ts +193 -0
  95. package/src/core/codec/index.ts +43 -0
  96. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  97. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  98. package/src/core/codec/input-descriptors.ts +373 -0
  99. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  100. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  101. package/src/core/codec/output-descriptors.ts +307 -0
  102. package/src/core/codec/types.ts +99 -36
  103. package/src/core/codec/well-known-inputs.ts +96 -0
  104. package/src/core/transport/agent-session.ts +330 -589
  105. package/src/core/transport/agent-view.ts +738 -0
  106. package/src/core/transport/client-session.ts +74 -69
  107. package/src/core/transport/decode-fold.ts +57 -47
  108. package/src/core/transport/headers.ts +57 -4
  109. package/src/core/transport/index.ts +2 -1
  110. package/src/core/transport/invocation.ts +1 -1
  111. package/src/core/transport/load-history-pages.ts +220 -0
  112. package/src/core/transport/load-history.ts +63 -61
  113. package/src/core/transport/pipe-stream.ts +10 -1
  114. package/src/core/transport/run-manager.ts +25 -31
  115. package/src/core/transport/session-support.ts +96 -0
  116. package/src/core/transport/tree.ts +414 -47
  117. package/src/core/transport/types/agent.ts +129 -102
  118. package/src/core/transport/types/client.ts +49 -13
  119. package/src/core/transport/types/tree.ts +61 -12
  120. package/src/core/transport/types/view.ts +57 -28
  121. package/src/core/transport/view.ts +520 -172
  122. package/src/core/transport/wire-log.ts +189 -0
  123. package/src/errors.ts +10 -3
  124. package/src/index.ts +44 -11
  125. package/src/react/contexts/client-session-context.ts +1 -1
  126. package/src/react/contexts/client-session-provider.tsx +38 -2
  127. package/src/react/index.ts +2 -1
  128. package/src/react/internal/skipped-session.ts +62 -0
  129. package/src/react/use-client-session.ts +7 -30
  130. package/src/react/use-view.ts +3 -3
  131. package/src/utils.ts +31 -97
  132. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  133. package/src/vercel/codec/events.ts +1 -3
  134. package/src/vercel/codec/fields.ts +58 -0
  135. package/src/vercel/codec/fold-content.ts +54 -0
  136. package/src/vercel/codec/fold-data.ts +46 -0
  137. package/src/vercel/codec/fold-input.ts +255 -0
  138. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  139. package/src/vercel/codec/fold-text.ts +55 -0
  140. package/src/vercel/codec/fold-tool-input.ts +86 -0
  141. package/src/vercel/codec/fold-tool-output.ts +79 -0
  142. package/src/vercel/codec/index.ts +23 -63
  143. package/src/vercel/codec/inputs.ts +116 -0
  144. package/src/vercel/codec/outputs.ts +207 -0
  145. package/src/vercel/codec/reducer-state.ts +169 -0
  146. package/src/vercel/codec/reducer.ts +52 -838
  147. package/src/vercel/codec/tool-transitions.ts +1 -12
  148. package/src/vercel/codec/wire-data.ts +64 -0
  149. package/src/vercel/index.ts +1 -0
  150. package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
  151. package/src/vercel/react/use-chat-transport.ts +8 -28
  152. package/src/vercel/react/use-message-sync.ts +5 -10
  153. package/src/vercel/run-end-reason.ts +95 -16
  154. package/src/vercel/tool-part.ts +25 -0
  155. package/src/vercel/transport/chat-transport.ts +10 -22
  156. package/src/vercel/transport/index.ts +1 -1
  157. package/src/vercel/transport/run-output-stream.ts +7 -8
  158. package/src/version.ts +1 -1
  159. package/dist/core/transport/branch-chain.d.ts +0 -43
  160. package/dist/core/transport/load-conversation.d.ts +0 -128
  161. package/dist/vercel/codec/decoder.d.ts +0 -9
  162. package/dist/vercel/codec/encoder.d.ts +0 -11
  163. package/src/core/transport/branch-chain.ts +0 -58
  164. package/src/core/transport/load-conversation.ts +0 -355
  165. package/src/vercel/codec/decoder.ts +0 -696
  166. package/src/vercel/codec/encoder.ts +0 -548
@@ -10,7 +10,7 @@ import type * as AI from 'ai';
10
10
  import { stripUndefined } from '../../utils.js';
11
11
 
12
12
  // ---------------------------------------------------------------------------
13
- // Tool output chunk type guard
13
+ // Tool output chunk type
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
16
  /** The set of UIMessageChunk types that represent tool output transitions. */
@@ -19,17 +19,6 @@ export type ToolOutputChunk = Extract<
19
19
  { type: 'tool-output-available' | 'tool-output-error' | 'tool-output-denied' | 'tool-approval-request' }
20
20
  >;
21
21
 
22
- /**
23
- * Whether a UIMessageChunk is a tool output transition event.
24
- * @param chunk - The chunk to test.
25
- * @returns True if the chunk is a tool output transition type.
26
- */
27
- export const isToolOutputChunk = (chunk: AI.UIMessageChunk): chunk is ToolOutputChunk =>
28
- chunk.type === 'tool-output-available' ||
29
- chunk.type === 'tool-output-error' ||
30
- chunk.type === 'tool-output-denied' ||
31
- chunk.type === 'tool-approval-request';
32
-
33
22
  // ---------------------------------------------------------------------------
34
23
  // Tool base helper
35
24
  // ---------------------------------------------------------------------------
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Wire-data shapes and runtime guards for the tool payloads whose `data`
3
+ * envelope is JSON-parsed from the network (a trust boundary). The guards
4
+ * validate the typed envelope fields; tool-defined `output`/`input` stay
5
+ * unconstrained. Shared by the output and input descriptor tables.
6
+ */
7
+
8
+ /** Wire format for the agent-side `tool-input-error` chunk data payload. */
9
+ export interface ToolInputErrorWireData {
10
+ errorText?: string;
11
+ input?: unknown;
12
+ }
13
+
14
+ /** Wire format for the `tool-output-available` (agent) / `tool-result` (client) data payload. */
15
+ export interface ToolOutputAvailableWireData {
16
+ output?: unknown;
17
+ }
18
+
19
+ /** Wire format for the agent-side `tool-output-error` chunk data payload. */
20
+ export interface AgentToolOutputErrorWireData {
21
+ errorText?: string;
22
+ }
23
+
24
+ /** Wire format for the client-side `tool-result-error` input data payload. */
25
+ export interface ClientToolResultErrorWireData {
26
+ message?: string;
27
+ }
28
+
29
+ // Narrow JSON-parsed wire data to a record. The encoder is expected to publish
30
+ // an object for these payloads, but a malformed publish could carry a primitive
31
+ // or null — callers fall back to field defaults when these guards reject.
32
+ const isRecord = (data: unknown): data is Record<string, unknown> => typeof data === 'object' && data !== null;
33
+
34
+ // Validate that `data` is a record whose named field is absent or a string. The
35
+ // optional-string check for the typed error fields below lives here once so the
36
+ // guards can't drift. No `as` needed: `isRecord` narrows `data` to a record, so
37
+ // string-key indexing is well-typed.
38
+ const isRecordWithOptionalString = (data: unknown, key: string): boolean =>
39
+ isRecord(data) && (data[key] === undefined || typeof data[key] === 'string');
40
+
41
+ // Validates the typed `errorText` field; `input` is tool-defined and
42
+ // intentionally left unconstrained.
43
+ /**
44
+ * Coerce wire `data` to a string, falling back to `''` for any non-string
45
+ * payload — the defensive read for descriptors whose data is plain text.
46
+ * @param data - The inbound wire data.
47
+ * @returns The string payload, or `''` when the data is not a string.
48
+ */
49
+ export const asString = (data: unknown): string => (typeof data === 'string' ? data : '');
50
+
51
+ export const isToolInputErrorWireData = (data: unknown): data is ToolInputErrorWireData =>
52
+ isRecordWithOptionalString(data, 'errorText');
53
+
54
+ // The sole field `output` is tool-defined and intentionally unconstrained, so
55
+ // this asserts only that the payload is an object envelope.
56
+ export const isToolOutputAvailableWireData = (data: unknown): data is ToolOutputAvailableWireData => isRecord(data);
57
+
58
+ // Validates the typed `errorText` field.
59
+ export const isAgentToolOutputErrorWireData = (data: unknown): data is AgentToolOutputErrorWireData =>
60
+ isRecordWithOptionalString(data, 'errorText');
61
+
62
+ // Validates the typed `message` field.
63
+ export const isClientToolResultErrorWireData = (data: unknown): data is ClientToolResultErrorWireData =>
64
+ isRecordWithOptionalString(data, 'message');
@@ -13,4 +13,5 @@ export type {
13
13
  export { createAgentSession, createChatTransport, createClientSession } from './transport/index.js';
14
14
 
15
15
  // Vercel-shaped helpers
16
+ export type { VercelRunOutcome } from './run-end-reason.js';
16
17
  export { vercelRunOutcome } from './run-end-reason.js';
@@ -23,7 +23,7 @@ export interface ChatTransportSlot {
23
23
  * The shape of the single {@link ChatTransportContext} value.
24
24
  * Combines the nearest slot with the full registry in one context object.
25
25
  */
26
- export interface ChatTransportContextValue {
26
+ interface ChatTransportContextValue {
27
27
  /** The slot from the nearest {@link ChatTransportProvider} in the tree. */
28
28
  readonly nearest: ChatTransportSlot | undefined;
29
29
  /** All registered slots, keyed by channelName. */
@@ -16,36 +16,13 @@ import * as Ably from 'ably';
16
16
  import type * as AI from 'ai';
17
17
  import { useContext } from 'react';
18
18
 
19
- import type { ClientSession, Tree, View } from '../../core/transport/types.js';
19
+ import type { ClientSession } from '../../core/transport/types.js';
20
20
  import { ErrorCode } from '../../errors.js';
21
+ import { makeSkippedClientSession } from '../../react/internal/skipped-session.js';
21
22
  import type { VercelInput, VercelOutput, VercelProjection } from '../codec/index.js';
22
23
  import type { ChatTransport } from '../transport/index.js';
23
24
  import { ChatTransportContext } from './contexts/chat-transport-context.js';
24
25
 
25
- const SKIPPED_CLIENT_SESSION: ClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage> = {
26
- get tree(): Tree<VercelOutput, VercelProjection> {
27
- throw new Ably.ErrorInfo('unable to access tree; hook is skipped', ErrorCode.InvalidArgument, 400);
28
- },
29
- get view(): View<VercelInput, AI.UIMessage> {
30
- throw new Ably.ErrorInfo('unable to access view; hook is skipped', ErrorCode.InvalidArgument, 400);
31
- },
32
- connect: () => {
33
- throw new Ably.ErrorInfo('unable to connect; hook is skipped', ErrorCode.InvalidArgument, 400);
34
- },
35
- createView: (): View<VercelInput, AI.UIMessage> => {
36
- throw new Ably.ErrorInfo('unable to create view; hook is skipped', ErrorCode.InvalidArgument, 400);
37
- },
38
- cancel: () => {
39
- throw new Ably.ErrorInfo('unable to cancel; hook is skipped', ErrorCode.InvalidArgument, 400);
40
- },
41
- on: () => {
42
- throw new Ably.ErrorInfo('unable to subscribe; hook is skipped', ErrorCode.InvalidArgument, 400);
43
- },
44
- close: () => {
45
- throw new Ably.ErrorInfo('unable to close; hook is skipped', ErrorCode.InvalidArgument, 400);
46
- },
47
- };
48
-
49
26
  const SKIPPED_CHAT_TRANSPORT: ChatTransport = {
50
27
  sendMessages: (): never => {
51
28
  throw new Ably.ErrorInfo('unable to send messages; hook is skipped', ErrorCode.InvalidArgument, 400);
@@ -131,7 +108,10 @@ export const useChatTransport = ({ channelName, skip }: UseChatTransportOptions
131
108
  const { nearest, providers } = useContext(ChatTransportContext);
132
109
 
133
110
  if (skip) {
134
- return { session: SKIPPED_CLIENT_SESSION, chatTransport: SKIPPED_CHAT_TRANSPORT };
111
+ return {
112
+ session: makeSkippedClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage>(),
113
+ chatTransport: SKIPPED_CHAT_TRANSPORT,
114
+ };
135
115
  }
136
116
 
137
117
  if (channelName !== undefined) {
@@ -140,7 +120,7 @@ export const useChatTransport = ({ channelName, skip }: UseChatTransportOptions
140
120
  return { session: slot.session, chatTransport: slot.chatTransport, sessionError: slot.sessionError };
141
121
  }
142
122
  return {
143
- session: SKIPPED_CLIENT_SESSION,
123
+ session: makeSkippedClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage>(),
144
124
  chatTransport: SKIPPED_CHAT_TRANSPORT,
145
125
  sessionError: new Ably.ErrorInfo(
146
126
  `unable to use client session; no ClientSessionProvider found for channelName "${channelName}"`,
@@ -164,7 +144,7 @@ export const useChatTransport = ({ channelName, skip }: UseChatTransportOptions
164
144
  }
165
145
 
166
146
  return {
167
- session: SKIPPED_CLIENT_SESSION,
147
+ session: makeSkippedClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage>(),
168
148
  chatTransport: SKIPPED_CHAT_TRANSPORT,
169
149
  sessionError: new Ably.ErrorInfo(
170
150
  'unable to use session; no ClientSessionProvider found in the tree',
@@ -16,6 +16,7 @@
16
16
  import type * as AI from 'ai';
17
17
  import { useEffect, useState } from 'react';
18
18
 
19
+ import { isToolPart, type ToolPart } from '../tool-part.js';
19
20
  import { useChatTransport } from './use-chat-transport.js';
20
21
 
21
22
  /** Options for {@link useMessageSync}. */
@@ -38,19 +39,13 @@ export interface UseMessageSyncOptions {
38
39
  // Tool-resolution merge
39
40
  // ---------------------------------------------------------------------------
40
41
  //
41
- // The Vercel codec normalises every tool part to `dynamic-tool`, but the
42
- // AI SDK emits `tool-${name}` for statically-declared tools. Both shapes
43
- // share `toolCallId` + `state`; the merge matches by toolCallId and keeps
44
- // the tree's `type` on the result so downstream consumers narrowing on
45
- // `dynamic-tool` keep working.
46
-
47
- type ToolPart = AI.DynamicToolUIPart | AI.ToolUIPart;
42
+ // The merge matches tool parts by toolCallId (via the shared {@link isToolPart}
43
+ // guard, which accepts both the codec's `dynamic-tool` shape and the AI SDK's
44
+ // `tool-${name}` shape) and keeps the tree's `type` on the result so downstream
45
+ // consumers narrowing on `dynamic-tool` keep working.
48
46
 
49
47
  const RESOLVED_TOOL_STATES = new Set(['output-available', 'output-error', 'approval-responded', 'output-denied']);
50
48
 
51
- const isToolPart = (part: AI.UIMessage['parts'][number]): part is ToolPart =>
52
- (part.type === 'dynamic-tool' || part.type.startsWith('tool-')) && 'toolCallId' in part && 'state' in part;
53
-
54
49
  const mergeAssistant = (tree: AI.UIMessage, overlay: AI.UIMessage): AI.UIMessage => {
55
50
  const overlayByCallId = new Map<string, ToolPart>();
56
51
  for (const part of overlay.parts) {
@@ -1,16 +1,76 @@
1
+ import * as Ably from 'ably';
1
2
  import type * as AI from 'ai';
2
3
 
3
4
  import type { RunEndReason, StreamResult } from '../core/transport/types.js';
5
+ import { ErrorCode } from '../errors.js';
4
6
 
5
7
  /**
6
- * Derive the outcome for a Vercel `streamText` response that was piped through
7
- * `Run.pipe`: either a terminal {@link RunEndReason} the caller passes to
8
- * `Run.end`, or the sentinel `'suspend'` telling the caller to call
9
- * `Run.suspend` instead. Preserves transport-level outcomes (`'cancelled'`,
10
- * `'error'`) from the pipe result; when the pipe completed naturally, awaits
11
- * Vercel's `finishReason` and returns `'suspend'` for `'tool-calls'` (the LLM
12
- * requested tools the SDK did not auto-execute, so the run should suspend
13
- * rather than end), or `'complete'` otherwise.
8
+ * The outcome of a Vercel `streamText` response piped through `Run.pipe`.
9
+ * Discriminated on `reason`: `'suspend'` means the run should pause; the
10
+ * non-`'suspend'` arms describe how it terminated, and an `'error'` outcome
11
+ * always carries `error`.
12
+ *
13
+ * This is a *description of what the Vercel run resulted in*, not a command to
14
+ * the SDK. The common case maps cleanly onto one transport action — `'suspend'`
15
+ * `Run.suspend()`, everything else → `Run.end()` — and to make that case a
16
+ * one-liner the non-`'suspend'` arms are deliberately assignable to
17
+ * {@link RunEndParams}, so after a `suspend` guard the whole object passes
18
+ * straight to `Run.end(outcome)`. That assignability is a convenience for this
19
+ * adapter, not a constraint on what an outcome can mean: responding to an
20
+ * outcome may also involve work outside this SDK (persisting a result,
21
+ * notifying a human, triggering a downstream workflow), and the developer is
22
+ * free to do that around the terminal call.
23
+ *
24
+ * The type is Vercel-specific by design. Outcomes are the layer where agent
25
+ * SDKs diverge most — both in what they report (the `'suspend'` arm exists only
26
+ * because Vercel surfaces unexecuted tool calls as a non-terminal finish) and
27
+ * in what a developer must do in response. A different SDK's outcome type would
28
+ * have different arms; hence each adapter names its own rather than sharing a
29
+ * single core `RunOutcome`. The vocabulary it bottoms out in
30
+ * ({@link RunEndParams}, `Run.suspend`/`Run.end`) is the shared, codec-agnostic
31
+ * part that does live in core.
32
+ */
33
+ export type VercelRunOutcome =
34
+ | {
35
+ /**
36
+ * The LLM requested tools the SDK did not auto-execute, so the run
37
+ * pauses rather than ending — call `Run.suspend()`.
38
+ */
39
+ reason: 'suspend';
40
+ /** Never present for a suspend outcome. */
41
+ error?: never;
42
+ }
43
+ | {
44
+ /** A non-error terminal reason; pass the outcome to `Run.end()`. */
45
+ reason: Exclude<RunEndReason, 'error'>;
46
+ /** Never present for a non-error outcome. */
47
+ error?: never;
48
+ }
49
+ | {
50
+ /** The run ended in error; pass the outcome to `Run.end()`. */
51
+ reason: Extract<RunEndReason, 'error'>;
52
+ /**
53
+ * The terminal error: the underlying stream / `finishReason` failure
54
+ * wrapped as an `Ably.ErrorInfo` (code `StreamError`).
55
+ */
56
+ error: Ably.ErrorInfo;
57
+ };
58
+
59
+ /**
60
+ * Derive the {@link VercelRunOutcome} for a Vercel `streamText` response that
61
+ * was piped through `Run.pipe`. Preserves transport-level outcomes
62
+ * (`'cancelled'`, `'error'`) from the pipe result; when the pipe completed
63
+ * naturally, awaits Vercel's `finishReason` and returns `'suspend'` for
64
+ * `'tool-calls'` (the LLM requested tools the SDK did not auto-execute, so the
65
+ * run should suspend rather than end), or `'complete'` otherwise.
66
+ *
67
+ * Surfaces the failure for both error shapes so the caller can forward it to
68
+ * `Run.end(reason, error)`: a stream that threw (`pipeResult.error`) and a
69
+ * `finishReason` that rejected with a non-abort error (e.g.
70
+ * `NoOutputGeneratedError`, network blow-ups). The error is wrapped as an
71
+ * `Ably.ErrorInfo` (code `StreamError`). A stream that already produced a
72
+ * codec-level error chunk is unaffected — stamping run-end is the
73
+ * codec-agnostic baseline that any consumer can read.
14
74
  *
15
75
  * Tolerates `finishReason` rejection. Vercel AI SDK v6 rejects
16
76
  * `streamText().finishReason` with the abort signal's reason when the stream
@@ -25,13 +85,13 @@ import type { RunEndReason, StreamResult } from '../core/transport/types.js';
25
85
  * of every route handler.
26
86
  * @param pipeResult - The result returned by `Run.pipe`.
27
87
  * @param finishReason - The `finishReason` promise from a `streamText` result.
28
- * @returns `'suspend'` when the run should suspend awaiting tool input, or the
29
- * {@link RunEndReason} to pass to `Run.end` otherwise.
88
+ * @returns The {@link VercelRunOutcome}: the terminal `reason` (or `'suspend'`)
89
+ * and, when `reason` is `'error'`, the wrapped `error` to pass to `Run.end`.
30
90
  */
31
91
  export const vercelRunOutcome = async (
32
92
  pipeResult: StreamResult,
33
93
  finishReason: PromiseLike<AI.FinishReason>,
34
- ): Promise<RunEndReason | 'suspend'> => {
94
+ ): Promise<VercelRunOutcome> => {
35
95
  if (pipeResult.reason !== 'complete') {
36
96
  // Vercel's `result.finishReason` getter creates the underlying Promise
37
97
  // eagerly, before the caller hands it to us. When `streamText` is
@@ -46,22 +106,41 @@ export const vercelRunOutcome = async (
46
106
  Promise.resolve(finishReason).catch(() => {
47
107
  /* intentionally discarded; reason already known from pipeResult */
48
108
  });
49
- return pipeResult.reason;
109
+ if (pipeResult.reason === 'error') {
110
+ return { reason: 'error', error: _toErrorInfo(pipeResult.error) };
111
+ }
112
+ return { reason: pipeResult.reason };
50
113
  }
51
114
  try {
52
115
  const finish = await finishReason;
53
- return finish === 'tool-calls' ? 'suspend' : 'complete';
116
+ if (finish === 'tool-calls') return { reason: 'suspend' };
117
+ return { reason: 'complete' };
54
118
  } catch (error) {
55
119
  // Abort-shaped rejections are surfaced from streamText when the run was
56
120
  // cancelled before any step finished — treat the run as cancelled so the
57
121
  // observable lifecycle matches the cancel that triggered it. Everything
58
122
  // else is a real error (e.g. NoOutputGeneratedError, network blow-ups);
59
- // surface it as such so the developer sees the failure rather than a
60
- // silent cancel.
61
- return _isAbortLikeError(error) ? 'cancelled' : 'error';
123
+ // surface it as such — wrapped so the caller can stamp it on run-end — so
124
+ // the developer sees the failure rather than a silent cancel.
125
+ if (_isAbortLikeError(error)) return { reason: 'cancelled' };
126
+ return { reason: 'error', error: _toErrorInfo(error) };
62
127
  }
63
128
  };
64
129
 
130
+ /**
131
+ * Wrap a caught stream / `finishReason` failure as an `Ably.ErrorInfo` so it
132
+ * can be passed to `Run.end(reason, error)`. An error that is already an
133
+ * `Ably.ErrorInfo` is returned unchanged; anything else is wrapped with code
134
+ * `StreamError`, mirroring how `Run.pipe` wraps stream errors for `onError`.
135
+ * @param error - The caught error (or `undefined` when the stream reported none).
136
+ * @returns The error as an `Ably.ErrorInfo`.
137
+ */
138
+ const _toErrorInfo = (error: unknown): Ably.ErrorInfo => {
139
+ if (error instanceof Ably.ErrorInfo) return error;
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ return new Ably.ErrorInfo(`unable to complete run; ${message}`, ErrorCode.StreamError, 500);
142
+ };
143
+
65
144
  /**
66
145
  * Heuristic for "this error came from an AbortSignal aborting".
67
146
  * Covers `DOMException` aborts (browser / Node 20+ `streamText`),
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared tool-part type guard for the Vercel layer.
3
+ *
4
+ * The codec normalises every tool part to the `dynamic-tool` shape, but the AI
5
+ * SDK emits `tool-${name}` parts for statically-declared tools. Both shapes
6
+ * carry `toolCallId` and `state`. The guard accepts either representation so
7
+ * the transport's unresolved-tool detection and the React overlay merge can
8
+ * match tool parts uniformly — and so the cross-representation rule lives in
9
+ * one place rather than being re-spelled per call site.
10
+ */
11
+
12
+ import type * as AI from 'ai';
13
+
14
+ /** A UIMessage tool part in either the `dynamic-tool` or `tool-${name}` representation. */
15
+ export type ToolPart = AI.DynamicToolUIPart | AI.ToolUIPart;
16
+
17
+ /**
18
+ * Whether a UIMessage part is a tool part of either representation. The
19
+ * `toolCallId`/`state` shape check is defensive against a future AI SDK release
20
+ * introducing a non-tool variant under the `tool-` prefix (none exists today).
21
+ * @param part - The UIMessage part to inspect.
22
+ * @returns True when the part is a tool part.
23
+ */
24
+ export const isToolPart = (part: AI.UIMessage['parts'][number]): part is ToolPart =>
25
+ (part.type === 'dynamic-tool' || part.type.startsWith('tool-')) && 'toolCallId' in part && 'state' in part;
@@ -31,13 +31,15 @@
31
31
  import * as Ably from 'ably';
32
32
  import type * as AI from 'ai';
33
33
 
34
- import type { CodecMessage } from '../../core/codec/types.js';
34
+ import type { CodecMessage } from '../../core/codec/index.js';
35
35
  import type { ActiveRun, ClientSession, SendOptions } from '../../core/transport/types.js';
36
36
  import { ErrorCode } from '../../errors.js';
37
37
  import { EventEmitter } from '../../event-emitter.js';
38
38
  import { LogLevel, makeLogger } from '../../logger.js';
39
+ import { errorCause, errorMessage } from '../../utils.js';
39
40
  import type { VercelInput, VercelOutput, VercelProjection } from '../codec/index.js';
40
41
  import { UIMessageCodec } from '../codec/index.js';
42
+ import { isToolPart, type ToolPart } from '../tool-part.js';
41
43
  import { createRunOutputStream } from './run-output-stream.js';
42
44
 
43
45
  // ---------------------------------------------------------------------------
@@ -71,7 +73,7 @@ export interface SendMessagesRequestContext {
71
73
  }
72
74
 
73
75
  /** Default agent endpoint the transport POSTs invocations to — mirrors Vercel's DefaultChatTransport. */
74
- export const DEFAULT_VERCEL_API = '/api/chat';
76
+ const DEFAULT_VERCEL_API = '/api/chat';
75
77
 
76
78
  /** Options for customizing the ChatTransport behavior. */
77
79
  export interface ChatTransportOptions {
@@ -226,18 +228,6 @@ const wrapStreamWithDone = <T>(
226
228
  // Unresolved tool call detection
227
229
  // ---------------------------------------------------------------------------
228
230
 
229
- /**
230
- * Whether a UIMessage part is a tool part — either the codec-normalised
231
- * `dynamic-tool` shape or the AI SDK's statically-declared `tool-${name}`
232
- * shape. Both carry `toolCallId` and `state`; the shape check at the end
233
- * is defensive against a future AI SDK release introducing a non-tool
234
- * variant under the `tool-` prefix (none exists today).
235
- * @param part - The UIMessage part to inspect.
236
- * @returns True when the part is a tool part of either representation.
237
- */
238
- const _isToolPart = (part: AI.UIMessage['parts'][number]): part is AI.DynamicToolUIPart | AI.ToolUIPart =>
239
- (part.type === 'dynamic-tool' || part.type.startsWith('tool-')) && 'toolCallId' in part && 'state' in part;
240
-
241
231
  /**
242
232
  * Whether an assistant message has a `dynamic-tool` part that can't resolve
243
233
  * without further user action. Matches:
@@ -254,7 +244,7 @@ const hasUnresolvedToolCall = (msg: AI.UIMessage): boolean =>
254
244
  msg.role === 'assistant' &&
255
245
  msg.parts.some(
256
246
  (p) =>
257
- _isToolPart(p) &&
247
+ isToolPart(p) &&
258
248
  (p.state === 'input-streaming' || p.state === 'input-available' || p.state === 'approval-requested'),
259
249
  );
260
250
 
@@ -280,7 +270,7 @@ const UNRESOLVED_TOOL_STATES = new Set(['input-streaming', 'input-available', 'a
280
270
  *
281
271
  * The resulting inputs are passed alongside the continuation `view.send`
282
272
  * so the channel publish and the continuation POST land as ONE atomic
283
- * operation — the agent's `loadProjection()` history fetch is guaranteed
273
+ * operation — the agent's `loadConversation()` history walk is guaranteed
284
274
  * to see them because the channel publish happens before the POST inside
285
275
  * `_internalSend`.
286
276
  *
@@ -314,15 +304,14 @@ const deriveContinuationInputs = (
314
304
  const { codecMessageId, message: treeMessage } = treeEntry;
315
305
 
316
306
  for (const overlayPart of overlay.parts) {
317
- if (!_isToolPart(overlayPart)) continue;
307
+ if (!isToolPart(overlayPart)) continue;
318
308
  // The codec normalises every tool part to `dynamic-tool`, but the
319
309
  // AI SDK's useChat overlay emits `tool-${name}` parts for statically
320
310
  // declared tools. Match by toolCallId rather than the type prefix
321
311
  // so the cross-representation comparison works regardless of which
322
312
  // side the tool was declared on.
323
313
  const treePart = treeMessage.parts.find(
324
- (p: AI.UIMessage['parts'][number]): p is AI.DynamicToolUIPart | AI.ToolUIPart =>
325
- _isToolPart(p) && p.toolCallId === overlayPart.toolCallId,
314
+ (p: AI.UIMessage['parts'][number]): p is ToolPart => isToolPart(p) && p.toolCallId === overlayPart.toolCallId,
326
315
  );
327
316
 
328
317
  // Approval response: useChat's `addToolApprovalResponse` flipped the
@@ -678,13 +667,12 @@ export const createChatTransport = (
678
667
  }
679
668
  })
680
669
  .catch((error: unknown) => {
681
- const cause = error instanceof Ably.ErrorInfo ? error : undefined;
682
670
  fail(
683
671
  new Ably.ErrorInfo(
684
- `unable to send; HTTP POST to ${api} failed: ${error instanceof Error ? error.message : String(error)}`,
672
+ `unable to send; HTTP POST to ${api} failed: ${errorMessage(error)}`,
685
673
  ErrorCode.SessionSendFailed,
686
674
  500,
687
- cause,
675
+ errorCause(error),
688
676
  ),
689
677
  );
690
678
  });
@@ -14,7 +14,7 @@
14
14
 
15
15
  // Chat transport adapter
16
16
  export type { ChatTransport, ChatTransportOptions, SendMessagesRequestContext } from './chat-transport.js';
17
- export { createChatTransport, DEFAULT_VERCEL_API } from './chat-transport.js';
17
+ export { createChatTransport } from './chat-transport.js';
18
18
 
19
19
  import type * as AI from 'ai';
20
20
 
@@ -41,21 +41,20 @@ type VercelSession = ClientSession<VercelInput, VercelOutput, VercelProjection,
41
41
  const isTerminalChunk = (output: VercelOutput): boolean =>
42
42
  output.type === 'finish' || output.type === 'error' || output.type === 'abort';
43
43
 
44
- /** A consumer-facing run output stream plus the handles to settle it externally. */
45
- export interface RunOutputStream {
44
+ /** A consumer-facing run output stream plus the handle to close it externally. */
45
+ interface RunOutputStream {
46
46
  /** The stream of decoded outputs for the run, as `useChat` consumes it. */
47
47
  stream: ReadableStream<VercelOutput>;
48
48
  /** Close the stream now (e.g. on local cancel). Idempotent. */
49
49
  close: () => void;
50
- /** Error the stream now (e.g. on a failed agent-invocation POST). Idempotent. */
51
- error: (reason: Ably.ErrorInfo) => void;
52
50
  }
53
51
 
54
52
  /**
55
53
  * Create a consumer-facing output stream for a send, sourced from the session
56
54
  * Tree's events. See the module docs for close/error semantics. The returned
57
- * `close`/`error` let the caller settle the stream for conditions the Tree
58
- * doesn't surface (local cancel, POST failure).
55
+ * `close` lets the caller settle the stream for conditions the Tree doesn't
56
+ * surface (local cancel). Session errors are wired internally to error the
57
+ * stream.
59
58
  *
60
59
  * Outputs route PURELY by the triggering input's codec-message-id — the key the
61
60
  * client owns from send time, before the agent mints the runId. The agent's
@@ -66,7 +65,7 @@ export interface RunOutputStream {
66
65
  * Used only by the run-end safety-net; routing keys on `inputCodecMessageId`.
67
66
  * @param inputCodecMessageId - The triggering input's codec-message-id. An
68
67
  * output routes to this stream when it carries this id.
69
- * @returns The stream and its external settle handles.
68
+ * @returns The stream and its external close handle.
70
69
  */
71
70
  export const createRunOutputStream = (
72
71
  session: VercelSession,
@@ -166,5 +165,5 @@ export const createRunOutputStream = (
166
165
  }),
167
166
  );
168
167
 
169
- return { stream, close, error };
168
+ return { stream, close };
170
169
  };
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** SDK version. Kept in sync with `package.json` by the `/release` workflow. */
2
- export const VERSION = '0.2.0';
2
+ export const VERSION = '0.3.0';
@@ -1,43 +0,0 @@
1
- /**
2
- * buildBranchChain — order a single conversation branch by walking
3
- * codec-message-id parent links upward from an anchor node to the root.
4
- *
5
- * This is the shared ordering spine of the agent's conversation
6
- * reconstruction and of history decode: both need the same root→anchor
7
- * sequence of nodes before folding each node's projection. Keeping the walk
8
- * here — pure, with no codec, no I/O, no logger — lets it be proven in
9
- * isolation and reused by both engines without drift.
10
- *
11
- * Branch selection is implicit: a node reaches only its own ancestors via
12
- * `parentCodecMessageId`, so sibling branches (edits / regenerates that the
13
- * anchor did not descend from) are never visited. There is no separate
14
- * fork/regenerate filtering step — the un-taken sibling is simply unreachable.
15
- */
16
- /**
17
- * The single field {@link buildBranchChain} reads from a node. Richer node-meta
18
- * shapes (carrying run-id, fork-of, regenerates, …) satisfy this structurally,
19
- * so callers can pass their full index map directly.
20
- */
21
- export interface BranchChainNode {
22
- /**
23
- * Codec-message-id of this node's structural parent — the node it hangs off
24
- * — or `undefined` for a root node. This is the only edge the walk follows.
25
- */
26
- parentCodecMessageId: string | undefined;
27
- }
28
- /**
29
- * Walk `parentCodecMessageId` links upward from `anchorCodecMessageId` and
30
- * return the branch it sits on, ordered root-first (oldest) to anchor (newest,
31
- * last). The anchor is always the final element.
32
- *
33
- * The walk stops at the root (a node with no parent), at a dangling parent
34
- * (a parent id absent from `nodeMeta` is still included as the chain head,
35
- * then the walk ends), or on revisiting a node (a cycle in malformed data is
36
- * broken best-effort rather than looping forever).
37
- * @param nodeMeta - Lookup from codec-message-id to its node meta. Need not
38
- * contain the anchor or every ancestor; missing entries simply end the walk.
39
- * @param anchorCodecMessageId - The codec-message-id to start the walk from
40
- * (the newest node on the branch; included in the result).
41
- * @returns The branch's codec-message-ids ordered root-first to anchor-last.
42
- */
43
- export declare const buildBranchChain: (nodeMeta: ReadonlyMap<string, BranchChainNode>, anchorCodecMessageId: string) => string[];