From f48cdf82fa7837cf1c4456a31a87188fcee38a63 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 3 Nov 2026 15:36:03 +0100 Subject: [PATCH] feat: message parts implementation --- .../__tests__/messageParts.test.ts | 336 ++++++++++++++++++ package/src/ai-components/index.ts | 3 + .../src/ai-components/message-parts/index.ts | 2 + .../message-parts/parseMessageParts.ts | 195 ++++++++++ .../src/ai-components/message-parts/types.ts | 159 +++++++++ 5 files changed, 695 insertions(+) create mode 100644 package/src/ai-components/__tests__/messageParts.test.ts create mode 100644 package/src/ai-components/message-parts/index.ts create mode 100644 package/src/ai-components/message-parts/parseMessageParts.ts create mode 100644 package/src/ai-components/message-parts/types.ts diff --git a/package/src/ai-components/__tests__/messageParts.test.ts b/package/src/ai-components/__tests__/messageParts.test.ts new file mode 100644 index 0000000000..731be7ecc8 --- /dev/null +++ b/package/src/ai-components/__tests__/messageParts.test.ts @@ -0,0 +1,336 @@ +import { + isAuthorizationExpired, + isAwaitingApproval, + isAwaitingAuthorization, + isAwaitingClient, + isDeclined, + isSupportedPart, + isToolCallFinished, + parseMessagePart, + parseMessageParts, +} from '../message-parts'; +import type { AIToolCallPart } from '../message-parts'; + +// Shaped as the router writes them (acceleration/internal/conversation/parts.go). +const reasoning = { + duration_ms: 4200, + finished_at: '2026-11-02T10:00:04Z', + id: 'r1', + preview: 'Let me check the calendar first.', + started_at: '2026-11-02T10:00:00Z', + status: 'completed', + summary: 'Checked the calendar', + type: 'ai_reasoning', + v: 1, +}; + +const approvalCall = { + approval: { + allow_title: 'Share', + decline_title: 'Not now', + message: 'Only your city is shared.', + reason: 'to check the local weather', + title: 'Share your location?', + }, + arguments: { reason: 'to check the local weather' }, + display_title: 'Checking your location', + executor: 'client', + id: 'call_1', + name: 'get_location', + status: 'awaiting_approval', + target_client_id: 'ios-1', + target_user_id: 'alex', + type: 'ai_tool_call', + v: 1, +}; + +const authorizationCall = { + authorization: { + expires_at: '2026-11-02T10:10:00.123Z', + handoff_token: 'tok', + message: 'Juno needs your Linear account to file the issue.', + provider: 'linear', + provider_name: 'Linear', + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/v1/agents/connectors/oauth/launch/abc', + }, + display_title: 'Filing a Linear issue', + executor: 'server', + id: 'call_2', + name: 'linear_create_issue', + status: 'awaiting_authorization', + target_user_id: 'alex', + type: 'ai_tool_call', + v: 1, +}; + +const signInWithUrl = (url: string) => + parseMessagePart({ + ...authorizationCall, + authorization: { ...authorizationCall.authorization, url }, + })?.toolCall?.authorization; + +describe('parseMessageParts', () => { + it('reads the steps in order and skips other attachments', () => { + const parts = parseMessageParts([ + reasoning, + { image_url: 'https://example.com/a.png', type: 'image' }, + approvalCall, + authorizationCall, + ]); + expect(parts.map((part) => part.id)).toEqual(['r1', 'call_1', 'call_2']); + expect(parts.map((part) => part.kind)).toEqual([ + 'ai_reasoning', + 'ai_tool_call', + 'ai_tool_call', + ]); + expect(parts.every(isSupportedPart)).toBe(true); + }); + + it('reads a reply as a local Vision-Agents router wrote it', () => { + // The attachments of one real reply, copied from the channel (2026-11-03, xai/grok-4.7). + const parts = parseMessageParts([ + { + duration_ms: 2387, + finished_at: '2026-11-03T14:20:45.15695058Z', + id: 'r1', + preview: "Searching for today's top tech headline.", + started_at: '2026-11-03T14:20:45.156092748Z', + status: 'completed', + summary: "Searching for today's top tech headline.", + type: 'ai_reasoning', + v: 1, + }, + { + display_title: 'Searching the web', + duration_ms: 687, + executor: 'server', + finished_at: '2026-11-03T14:20:45.844631174Z', + id: 'call-b1352079-bc55-4229-ab07-8f9c2fc5d4d7-0', + name: 'search', + started_at: '2026-11-03T14:20:45.15688333Z', + status: 'completed', + type: 'ai_tool_call', + v: 1, + }, + { + duration_ms: 1884, + finished_at: '2026-11-03T14:20:47.729154697Z', + id: 'r2', + preview: "I will search for today's top tech headline and summarize it in one sentence.", + started_at: '2026-11-03T14:20:47.72908728Z', + status: 'completed', + summary: "I will search for today's top tech headline and summarize it in one sentence.", + type: 'ai_reasoning', + v: 1, + }, + ]); + expect(parts.map((part) => part.id)).toEqual([ + 'r1', + 'call-b1352079-bc55-4229-ab07-8f9c2fc5d4d7-0', + 'r2', + ]); + expect(parts[0].reasoning).toEqual({ + durationMs: 2387, + id: 'r1', + preview: "Searching for today's top tech headline.", + status: 'completed', + summary: "Searching for today's top tech headline.", + }); + expect(parts[1].toolCall).toMatchObject({ + displayTitle: 'Searching the web', + durationMs: 687, + executor: 'server', + name: 'search', + status: 'completed', + }); + expect(parts.every(isSupportedPart)).toBe(true); + }); + + it('reads a reasoning step', () => { + expect(parseMessagePart(reasoning)?.reasoning).toEqual({ + durationMs: 4200, + id: 'r1', + preview: 'Let me check the calendar first.', + status: 'completed', + summary: 'Checked the calendar', + }); + }); + + it('reads a tool call with its approval', () => { + const call = parseMessagePart(approvalCall)?.toolCall; + expect(call).toMatchObject({ + approval: { + allowTitle: 'Share', + declineTitle: 'Not now', + message: 'Only your city is shared.', + reason: 'to check the local weather', + title: 'Share your location?', + }, + arguments: { reason: 'to check the local weather' }, + displayTitle: 'Checking your location', + executor: 'client', + id: 'call_1', + name: 'get_location', + status: 'awaiting_approval', + targetClientId: 'ios-1', + targetUserId: 'alex', + }); + }); + + it('reads a tool call with its authorization', () => { + const call = parseMessagePart(authorizationCall)?.toolCall; + expect(call?.authorization).toEqual({ + expiresAt: Date.parse('2026-11-02T10:10:00.123Z'), + handoffToken: 'tok', + message: 'Juno needs your Linear account to file the issue.', + provider: 'linear', + providerName: 'Linear', + status: undefined, + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/v1/agents/connectors/oauth/launch/abc', + }); + }); + + it('defaults missing fields', () => { + const part = parseMessagePart({ type: 'ai_tool_call' }, 3); + expect(part?.id).toBe('ai_tool_call-3'); + expect(part?.version).toBe(1); + expect(part?.toolCall).toMatchObject({ + executor: 'server', + name: '', + status: 'running', + }); + expect(parseMessagePart({ type: 'ai_reasoning' })?.reasoning?.status).toBe('completed'); + }); + + it('reads fields of the wrong type as missing', () => { + const call = parseMessagePart({ + approval: { message: 'no title' }, + authorization: { title: 'Connect', url: 'ftp://example.com/launch' }, + duration_ms: '12', + id: 7, + name: ['x'], + status: '', + type: 'ai_tool_call', + })?.toolCall; + expect(call).toMatchObject({ durationMs: undefined, id: 'ai_tool_call-0', name: '' }); + expect(call?.status).toBe('running'); + // a question with no title asks nothing, and a sign-in needs a web page + expect(call?.approval).toBeUndefined(); + expect(call?.authorization).toBeUndefined(); + }); + + it('opens a sign-in only on a web page with a host', () => { + expect( + signInWithUrl('http://localhost:8080/v1/agents/connectors/oauth/launch/abc'), + ).toBeDefined(); + expect(signInWithUrl('HTTPS://router.example.com')).toBeDefined(); + expect(signInWithUrl('https://user@router.example.com/launch')).toBeDefined(); + expect(signInWithUrl('https://')).toBeUndefined(); + expect(signInWithUrl('https:///launch')).toBeUndefined(); + expect(signInWithUrl('javascript:alert(1)')).toBeUndefined(); + expect(signInWithUrl('router.example.com/launch')).toBeUndefined(); + }); + + it('reads a sign-in where URL does not implement protocol or host', () => { + // React Native before 0.80 throws "not implemented" from these getters. + const OriginalURL = globalThis.URL; + class LimitedURL { + get host(): string { + throw new Error('URL.host is not implemented'); + } + get protocol(): string { + throw new Error('URL.protocol is not implemented'); + } + } + globalThis.URL = LimitedURL as unknown as typeof URL; + try { + expect(parseMessagePart(authorizationCall)?.toolCall?.authorization).toBeDefined(); + } finally { + globalThis.URL = OriginalURL; + } + }); + + it('keeps steps it cannot read, without a typed view', () => { + const newer = parseMessagePart({ id: 'x', type: 'ai_tool_call', v: 2 }); + expect(newer?.toolCall).toBeUndefined(); + expect(newer && isSupportedPart(newer)).toBe(false); + const custom = parseMessagePart({ id: 'c', type: 'ai_citation', url: 'u' }); + expect(custom?.kind).toBe('ai_citation'); + expect(custom?.payload).toMatchObject({ url: 'u' }); + expect(custom && isSupportedPart(custom)).toBe(false); + }); + + it('ignores what is not an AI step', () => { + expect(parseMessageParts(undefined)).toEqual([]); + expect(parseMessageParts([null, 'x', { type: 'file' }, {}])).toEqual([]); + }); +}); + +describe('tool call addressing', () => { + const call = (overrides: Partial): AIToolCallPart => ({ + ...(parseMessagePart(approvalCall)?.toolCall as AIToolCallPart), + ...overrides, + }); + + it('asks the targeted person on the targeted install', () => { + expect(isAwaitingApproval(call({}), 'alex', 'ios-1')).toBe(true); + expect(isAwaitingApproval(call({}), 'alex', 'android-2')).toBe(false); + expect(isAwaitingApproval(call({}), 'sam', 'ios-1')).toBe(false); + }); + + it('asks a server tool on any of the person’s installs', () => { + const server = call({ executor: 'server', targetClientId: undefined }); + expect(isAwaitingApproval(server, 'alex', 'ios-1')).toBe(true); + expect(isAwaitingApproval(server, 'alex', 'android-9')).toBe(true); + }); + + it('asks nothing once answered or without a question', () => { + expect(isAwaitingApproval(call({ status: 'running' }), 'alex', 'ios-1')).toBe(false); + expect(isAwaitingApproval(call({ approval: undefined }), 'alex', 'ios-1')).toBe(false); + }); + + it('waits for this device to run a client tool', () => { + const device = call({ status: 'awaiting_client' }); + expect(isAwaitingClient(device, 'alex', 'ios-1')).toBe(true); + expect(isAwaitingClient(device, 'alex', 'ios-2')).toBe(false); + expect(isAwaitingClient(call({ executor: 'server' }), 'alex', 'ios-1')).toBe(false); + }); + + it('asks the targeted person to sign in from any install', () => { + const signIn = parseMessagePart(authorizationCall)?.toolCall as AIToolCallPart; + expect(isAwaitingAuthorization(signIn, 'alex')).toBe(true); + expect(isAwaitingAuthorization(signIn, 'sam')).toBe(false); + expect(isAwaitingAuthorization({ ...signIn, status: 'completed' }, 'alex')).toBe(false); + }); + + it('knows a declined call', () => { + const declinedApproval = call({ + approval: { decision: 'declined', title: 'Share?' }, + status: 'cancelled', + }); + expect(isDeclined(declinedApproval)).toBe(true); + const signIn = parseMessagePart(authorizationCall)?.toolCall as AIToolCallPart; + expect( + isDeclined({ + ...signIn, + authorization: { ...signIn.authorization!, status: 'declined' }, + }), + ).toBe(true); + expect(isDeclined(call({}))).toBe(false); + }); + + it('knows finished statuses and expired sign-ins', () => { + expect(['completed', 'failed', 'cancelled'].every(isToolCallFinished)).toBe(true); + expect(isToolCallFinished('awaiting_client')).toBe(false); + const signIn = parseMessagePart(authorizationCall)?.toolCall?.authorization as NonNullable< + AIToolCallPart['authorization'] + >; + expect(isAuthorizationExpired(signIn, signIn.expiresAt! - 1)).toBe(false); + expect(isAuthorizationExpired(signIn, signIn.expiresAt!)).toBe(true); + expect(isAuthorizationExpired({ ...signIn, status: 'connected' }, Infinity)).toBe(false); + }); +}); diff --git a/package/src/ai-components/index.ts b/package/src/ai-components/index.ts index f037bbfa42..dbcd54096a 100644 --- a/package/src/ai-components/index.ts +++ b/package/src/ai-components/index.ts @@ -7,6 +7,9 @@ export * from './StreamingMessageView'; export * from './AITypingIndicatorView'; export * from './voice-visualization'; +// agent replies +export * from './message-parts'; + // components export * from './components'; diff --git a/package/src/ai-components/message-parts/index.ts b/package/src/ai-components/message-parts/index.ts new file mode 100644 index 0000000000..9ade54059a --- /dev/null +++ b/package/src/ai-components/message-parts/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './parseMessageParts'; diff --git a/package/src/ai-components/message-parts/parseMessageParts.ts b/package/src/ai-components/message-parts/parseMessageParts.ts new file mode 100644 index 0000000000..999412e4c1 --- /dev/null +++ b/package/src/ai-components/message-parts/parseMessageParts.ts @@ -0,0 +1,195 @@ +import type { + AIMessagePartData, + AIReasoningPart, + AIToolApprovalData, + AIToolAuthorizationData, + AIToolCallPart, + AIToolCallStatus, +} from './types'; + +/** The attachment type prefix every AI step uses. */ +export const AI_MESSAGE_PART_TYPE_PREFIX = 'ai_'; +/** The newest format version of the built-in kinds this SDK understands. */ +export const AI_MESSAGE_PART_SUPPORTED_VERSION = 1; + +type JSONObject = Record; + +// Lenient reads: a field of the wrong type reads as missing, and an empty string as absent. +const isObject = (value: unknown): value is JSONObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const string = (object: JSONObject, key: string) => { + const value = object[key]; + return typeof value === 'string' && value !== '' ? value : undefined; +}; + +const int = (object: JSONObject, key: string) => { + const value = object[key]; + return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : undefined; +}; + +const date = (object: JSONObject, key: string) => { + const value = string(object, key); + if (value === undefined) return undefined; + const time = Date.parse(value); + return Number.isNaN(time) ? undefined : time; +}; + +const parseApproval = (object: JSONObject): AIToolApprovalData | undefined => { + // A question with no title asks nothing. + const title = string(object, 'title'); + if (!title) return undefined; + return { + allowTitle: string(object, 'allow_title'), + decision: string(object, 'decision'), + declineTitle: string(object, 'decline_title'), + message: string(object, 'message'), + reason: string(object, 'reason'), + title, + }; +}; + +// An http(s) URL with a host. Matched rather than read through `URL`, whose `protocol` and +// `host` throw "not implemented" on React Native before 0.80. +const WEB_URL = /^https?:\/\/[^\s/?#@]+(?:@[^\s/?#@]+)?(?:[/?#]|$)/i; + +const isWebURL = (value: string) => WEB_URL.test(value); + +const parseAuthorization = (object: JSONObject): AIToolAuthorizationData | undefined => { + // A sign-in with no title, or no web page to open, asks nothing. + const title = string(object, 'title'); + const url = string(object, 'url'); + if (!title || !url || !isWebURL(url)) return undefined; + return { + expiresAt: date(object, 'expires_at'), + handoffToken: string(object, 'handoff_token'), + message: string(object, 'message'), + provider: string(object, 'provider') ?? '', + providerName: string(object, 'provider_name'), + status: string(object, 'status'), + title, + type: string(object, 'type') ?? 'oauth', + url, + }; +}; + +const parseReasoning = (id: string, object: JSONObject): AIReasoningPart => ({ + durationMs: int(object, 'duration_ms'), + id, + preview: string(object, 'preview'), + status: string(object, 'status') ?? 'completed', + summary: string(object, 'summary'), +}); + +const parseToolCall = (id: string, object: JSONObject): AIToolCallPart => { + const approval = object.approval; + const authorization = object.authorization; + const args = object.arguments; + return { + approval: isObject(approval) ? parseApproval(approval) : undefined, + arguments: args === null ? undefined : args, + authorization: isObject(authorization) ? parseAuthorization(authorization) : undefined, + displayTitle: string(object, 'display_title'), + durationMs: int(object, 'duration_ms'), + executor: string(object, 'executor') ?? 'server', + id, + name: string(object, 'name') ?? '', + status: string(object, 'status') ?? 'running', + summary: string(object, 'summary'), + targetClientId: string(object, 'target_client_id'), + targetUserId: string(object, 'target_user_id'), + }; +}; + +/** + * Decodes one attachment, or returns `undefined` when it is not an AI step. + * + * @param attachment The attachment, as it is on the message (with its `type`). + * @param position The attachment's index, used as the identity of a step with no ID of its own. + */ +export const parseMessagePart = ( + attachment: unknown, + position = 0, +): AIMessagePartData | undefined => { + if (!isObject(attachment)) return undefined; + const type = attachment.type; + if (typeof type !== 'string' || !type.startsWith(AI_MESSAGE_PART_TYPE_PREFIX)) { + return undefined; + } + const id = string(attachment, 'id') ?? `${type}-${position}`; + const version = int(attachment, 'v') ?? 1; + const known = version <= AI_MESSAGE_PART_SUPPORTED_VERSION; + return { + id, + kind: type, + payload: attachment, + reasoning: known && type === 'ai_reasoning' ? parseReasoning(id, attachment) : undefined, + toolCall: known && type === 'ai_tool_call' ? parseToolCall(id, attachment) : undefined, + version, + }; +}; + +/** + * Decodes the AI steps among a message's attachments, in order. Attachments that are not AI + * steps (images, files and so on) are skipped. Decoding is lenient: missing fields get + * defaults, and a step newer than this SDK understands keeps its payload but has no typed + * view. + * + * ```ts + * const parts = parseMessageParts(message.attachments); + * ``` + */ +export const parseMessageParts = (attachments: readonly unknown[] | null | undefined) => + (attachments ?? []).reduce((parts, attachment, position) => { + const part = parseMessagePart(attachment, position); + if (part) parts.push(part); + return parts; + }, []); + +/** Whether this SDK has a typed view of the step. */ +export const isSupportedPart = (part: AIMessagePartData) => + part.reasoning !== undefined || part.toolCall !== undefined; + +/** Whether the call has finished, one way or another. */ +export const isToolCallFinished = (status: AIToolCallStatus) => + status === 'completed' || status === 'failed' || status === 'cancelled'; + +/** + * Whether the person declined the call, or the sign-in it waited for, so it never ran. + */ +export const isDeclined = (call: AIToolCallPart) => + call.approval?.decision === 'declined' || call.authorization?.status === 'declined'; + +/** + * Whether this call is waiting for this device: a client tool, still awaiting its result, + * targeted at this person and this install. + */ +export const isAwaitingClient = (call: AIToolCallPart, userId: string, clientId: string) => + call.executor === 'client' && + call.status === 'awaiting_client' && + call.targetUserId === userId && + call.targetClientId === clientId; + +/** + * Whether this call is waiting for this person to allow it, on this device: still awaiting + * approval, targeted at this person, and at this install when it names one (a client tool + * does; a server tool's question may be answered from any of their devices). + */ +export const isAwaitingApproval = (call: AIToolCallPart, userId: string, clientId: string) => + call.status === 'awaiting_approval' && + call.approval !== undefined && + call.targetUserId === userId && + (call.targetClientId === undefined || call.targetClientId === clientId); + +/** Whether this call is waiting for this person to sign in, from any of their devices. */ +export const isAwaitingAuthorization = (call: AIToolCallPart, userId: string) => + call.status === 'awaiting_authorization' && + call.authorization !== undefined && + call.targetUserId === userId; + +/** Whether a sign-in that is still asked for has passed its `expiresAt`. */ +export const isAuthorizationExpired = (authorization: AIToolAuthorizationData, now = Date.now()) => + authorization.status === 'expired' || + (authorization.status === undefined && + authorization.expiresAt !== undefined && + authorization.expiresAt <= now); diff --git a/package/src/ai-components/message-parts/types.ts b/package/src/ai-components/message-parts/types.ts new file mode 100644 index 0000000000..9d17ea2d2b --- /dev/null +++ b/package/src/ai-components/message-parts/types.ts @@ -0,0 +1,159 @@ +/** + * The steps an AI agent took while replying, such as a round of reasoning or a tool call. + * + * The agent writes each step as a custom attachment on its reply (`ai_reasoning`, + * `ai_tool_call`), and the order of the attachments is the order of the steps. The final + * answer stays in the message text and is shown after them. The React Native counterpart of + * `AIMessagePart` in StreamChatAI (iOS), stream-chat-android-ai-compose and + * `stream-chat-react/ai-components`. + * + * The kinds, statuses and decisions are open sets: compare against the values you know and + * give the rest a fallback. A step newer than this SDK understands keeps its payload but has + * no typed view. + */ + +// Keeps autocompletion for the known values while accepting any string. +type Open = T | (string & {}); + +export type AIMessagePartKind = Open<'ai_reasoning' | 'ai_tool_call'>; + +/** Where a round of reasoning is. Defaults to `completed` when the agent sends none. */ +export type AIReasoningStatus = Open<'streaming' | 'completed'>; + +/** A round of the model's reasoning (`ai_reasoning`). */ +export type AIReasoningPart = { + id: string; + status: AIReasoningStatus; + /** How long the model thought, in milliseconds. */ + durationMs?: number; + /** + * A capped excerpt: the latest thoughts while streaming, the opening once done. The full + * reasoning, when an app has it, arrives separately. + */ + preview?: string; + /** A one-line summary of the reasoning, once it is done. */ + summary?: string; +}; + +/** + * Where a tool call is. Treat a status this SDK doesn't know as still in progress. + * + * - `running`: the agent's backend or a device is running it. + * - `awaiting_client`: waiting for the targeted device to run the tool and send its result. + * - `awaiting_approval`: waiting for the targeted person to allow or decline it. + * - `awaiting_authorization`: waiting for the targeted person to sign in at a provider. + * - `completed`, `failed`, `cancelled`: finished, one way or another. + */ +export type AIToolCallStatus = Open< + | 'running' + | 'awaiting_client' + | 'awaiting_approval' + | 'awaiting_authorization' + | 'completed' + | 'failed' + | 'cancelled' +>; + +/** Who runs a tool: the agent's backend (`server`) or a person's device (`client`). */ +export type AIToolExecutor = Open<'server' | 'client'>; + +export type AIToolApprovalDecision = Open<'allowed' | 'declined'>; + +/** + * What a tool call asks the person it waits for before it runs, such as "Share your + * location?", and how they answered. Allowed, the call goes on; declined, it is cancelled + * and never runs. + */ +export type AIToolApprovalData = { + /** The question, such as "Share your location?". */ + title: string; + /** The label of the button that allows the call. Defaults to "Allow" when the agent sends none. */ + allowTitle?: string; + /** How the person answered, once they did. */ + decision?: AIToolApprovalDecision; + /** The label of the button that declines the call. Defaults to "Don't Allow". */ + declineTitle?: string; + /** What allowing it shares or does, such as "Only your city is shared." */ + message?: string; + /** The agent's own words for why it wants the call, such as "to check the local weather". */ + reason?: string; +}; + +/** How a person signs in: `oauth` is a consent at the provider, in a browser. */ +export type AIToolAuthorizationType = Open<'oauth'>; + +/** + * How a sign-in ended. `failed` is one the provider or the agent's backend ended without + * connecting, such as when the person came back as another account. + */ +export type AIToolAuthorizationStatus = Open<'connected' | 'declined' | 'expired' | 'failed'>; + +/** + * A sign-in a tool call waits for, such as "Connect Linear", and how it ended. It holds no + * credential: `url` is the agent's own sign-in page, and the provider hands what it grants to + * the agent's backend, never to the app. + */ +export type AIToolAuthorizationData = { + /** The provider, such as `linear`. */ + provider: string; + /** The question, such as "Connect Linear". */ + title: string; + type: AIToolAuthorizationType; + /** The sign-in page to open (the router's launch page). An `http(s)` URL. */ + url: string; + /** When the sign-in ends, in milliseconds since the epoch. */ + expiresAt?: number; + /** + * What binds the sign-in to the first browser that opens it. The launch page reads it from its + * URL's fragment. Gone once the sign-in is over. + */ + handoffToken?: string; + /** What signing in means for the person. */ + message?: string; + /** What people call the provider, such as "Linear". */ + providerName?: string; + /** How it ended, once it did. */ + status?: AIToolAuthorizationStatus; +}; + +/** A tool the agent called, run by the agent's backend or by a person's device. */ +export type AIToolCallPart = { + /** The model provider's tool-call ID. A device's result is matched against it. */ + id: string; + name: string; + /** Defaults to `server` when the agent sends none. */ + executor: AIToolExecutor; + /** Defaults to `running` when the agent sends none. */ + status: AIToolCallStatus; + /** What the call asks the person it waits for, and how they answered, when its tool asks first. */ + approval?: AIToolApprovalData; + /** The call's arguments, present for client tools, which need them to run. */ + arguments?: unknown; + /** The sign-in the call waits for, and how it ended, when it needs the person's account at a provider. */ + authorization?: AIToolAuthorizationData; + /** What the call is doing, in words for people, such as "Checking your location". */ + displayTitle?: string; + durationMs?: number; + /** A short, shareable outcome, such as "Found your location". */ + summary?: string; + /** The install that must run a client tool. */ + targetClientId?: string; + /** The person the call waits for. */ + targetUserId?: string; +}; + +/** One step of a reply, decoded from one `ai_*` attachment. */ +export type AIMessagePartData = { + /** The step's stable identity, for keys and animation. Tool calls use the provider's tool-call ID. */ + id: string; + /** The attachment type, such as `ai_reasoning`. */ + kind: AIMessagePartKind; + /** The attachment as it came, for kinds of your own or fields this SDK does not read. */ + payload: Record; + /** The step's format version (`v`). It changes only when a kind changes incompatibly. */ + version: number; + /** The step as a round of reasoning, when it is one this SDK can read. */ + reasoning?: AIReasoningPart; + /** The step as a tool call, when it is one this SDK can read. */ + toolCall?: AIToolCallPart; +};