@inferencesh/sdk 0.6.51 → 0.6.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,6 +176,32 @@ const task = await client.tasks.run(
176
176
  await client.tasks.cancel(task.id);
177
177
  ```
178
178
 
179
+ ### Stream Functions (Live Sockets)
180
+
181
+ A stream function keeps a socket open with its caller for the life of the task: frames go both ways until the caller closes or the app returns. `client.live` starts the task and dials its socket; the run response carries where to dial (`task.socket`).
182
+
183
+ ```typescript
184
+ const { task, session } = await client.live(
185
+ { app: 'infsh/voice-loop', function: 'stream', input: { effect: 'robot' } },
186
+ {
187
+ onState: (state) => console.log(state), // connecting → waiting → live → ended
188
+ onBinary: (pcm) => speaker.write(new Int16Array(pcm)),
189
+ onPatch: (patch) => console.log(patch), // e.g. { frames: 120 }
190
+ }
191
+ );
192
+
193
+ session.sendBinary(micFrame); // one item of the input's binary live field
194
+ session.sendPatch({ effect: 'echo' }); // change an ordinary input while it runs
195
+ session.close(); // the function returns and the task completes
196
+ await session.ended;
197
+ ```
198
+
199
+ The session is `waiting` until the app's first frame (a cold start can take a minute) and gives up if the task ends before then. It dials again with a fresh credential when the relay restarts under it. `client.sockets.open(taskOrId, handlers)` reconnects to a running task's socket, e.g. after a page reload.
200
+
201
+ What a function's socket carries is in its schemas: a live field is `{"type": "array", "format": "stream", "items": ...}`. `splitLiveSchema(schema)` separates the ordinary fields (the request body) from the live ones, and `pcmFormat(field.media)` reads the sample rate of a PCM audio field.
202
+
203
+ On Node 18–21 there is no global `WebSocket`: pass one from the `ws` package as `{ webSocket: WebSocket }`.
204
+
179
205
  ### Sessions (Stateful Execution)
180
206
 
181
207
  Sessions allow you to maintain state across multiple task invocations. The worker stays warm between calls, preserving loaded models and in-memory state.
@@ -363,7 +389,7 @@ import {
363
389
  mcpTool,
364
390
  internalTools,
365
391
  string,
366
- IntegrationProviderGoogle,
392
+ CredentialProviderGoogle,
367
393
  } from '@inferencesh/sdk';
368
394
 
369
395
  const clientTool = tool('get_weather')
@@ -375,7 +401,7 @@ const clientTool = tool('get_weather')
375
401
  const gmailSend = httpTool('gmail_send', 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send')
376
402
  .describe('Send an email via Gmail')
377
403
  .method('POST')
378
- .auth({ integration: IntegrationProviderGoogle, integrationId: 'your-integration-id' })
404
+ .auth({ integration: CredentialProviderGoogle, integrationId: 'your-integration-id' })
379
405
  .build();
380
406
 
381
407
  // API key or bearer auth
@@ -588,6 +614,18 @@ const agent = client.agents.create({
588
614
  | `keepalive(sessionId)` | `POST /sessions/{id}/keepalive` | Reset idle expiration |
589
615
  | `end(sessionId)` | `DELETE /sessions/{id}` | End session and release worker |
590
616
 
617
+ ### `client.sockets`
618
+
619
+ | Method | HTTP | Description |
620
+ |--------|------|-------------|
621
+ | `open(taskOrId, handlers?, options?)` | — | Dial a stream task's socket; returns a `LiveSession` |
622
+ | `get(socketId)` | `GET /sockets/{id}` | The socket and what is known of its life |
623
+ | `forTask(taskId)` | `POST /sockets/list` | The task's socket, or null |
624
+ | `access(socketId)` | `POST /sockets/{id}/access` | A fresh credential for the caller's end |
625
+ | `delete(socketId)` | `DELETE /sockets/{id}` | Delete the record |
626
+
627
+ `client.live(params, handlers?, options?)` is `tasks.run(params, { wait: false })` followed by `sockets.open`.
628
+
591
629
  ## Task Status Constants
592
630
 
593
631
  ```typescript
@@ -606,24 +644,24 @@ if (task.status === TaskStatusCompleted) {
606
644
 
607
645
  ## Integration Constants
608
646
 
609
- `IntegrationDTO` fields (`provider`, `type`, `auth`, `status`) use typed string unions exported as constants:
647
+ `CredentialDTO` fields (`provider`, `type`, `auth`, `status`) use typed string unions exported as constants:
610
648
 
611
649
  ```typescript
612
- import type { IntegrationDTO } from '@inferencesh/sdk';
650
+ import type { CredentialDTO } from '@inferencesh/sdk';
613
651
  import {
614
- IntegrationProviderGoogle,
615
- IntegrationAuthTypeOAuth,
616
- IntegrationStatusConnected,
617
- IntegrationStatusDisconnected,
618
- IntegrationStatusExpired,
619
- IntegrationStatusError,
652
+ CredentialProviderGoogle,
653
+ CredentialTypeOAuth,
654
+ CredentialStatusConnected,
655
+ CredentialStatusDisconnected,
656
+ CredentialStatusExpired,
657
+ CredentialStatusError,
620
658
  isRequirementsNotMetException,
621
659
  } from '@inferencesh/sdk';
622
660
 
623
- function isGoogleConnected(integration: IntegrationDTO): boolean {
661
+ function isGoogleConnected(integration: CredentialDTO): boolean {
624
662
  return (
625
- integration.provider === IntegrationProviderGoogle &&
626
- integration.status === IntegrationStatusConnected
663
+ integration.provider === CredentialProviderGoogle &&
664
+ integration.status === CredentialStatusConnected
627
665
  );
628
666
  }
629
667
 
@@ -633,7 +671,7 @@ try {
633
671
  } catch (error) {
634
672
  if (isRequirementsNotMetException(error)) {
635
673
  for (const req of error.errors) {
636
- if (req.type === 'integration' && req.action?.provider === IntegrationProviderGoogle) {
674
+ if (req.type === 'integration' && req.action?.provider === CredentialProviderGoogle) {
637
675
  // User must connect Google — see https://inference.sh/docs/extend/integrations
638
676
  }
639
677
  }
@@ -643,9 +681,9 @@ try {
643
681
 
644
682
  | Constant group | Values |
645
683
  |----------------|--------|
646
- | `IntegrationProvider*` | `google`, `slack`, `notion`, `github`, `x`, `microsoft`, `salesforce`, `discord`, `gcp`, `mcp`, `reddit` |
647
- | `IntegrationAuthType*` | `service_account`, `oauth`, `api_key`, `wif`, `mcp` |
648
- | `IntegrationStatus*` | `connected`, `disconnected`, `expired`, `error` |
684
+ | `CredentialProvider*` | `google`, `slack`, `notion`, `github`, `x`, `microsoft`, `salesforce`, `discord`, `gcp`, `mcp`, `reddit` |
685
+ | `CredentialType*` | `service_account`, `oauth`, `api_key`, `wif`, `mcp` |
686
+ | `CredentialStatus*` | `connected`, `disconnected`, `expired`, `error` |
649
687
 
650
688
  ## Instance Status Constants
651
689
 
@@ -698,7 +736,7 @@ import type {
698
736
  Task,
699
737
  ApiAppRunRequest,
700
738
  RunOptions,
701
- IntegrationDTO,
739
+ CredentialDTO,
702
740
  AgentTool,
703
741
  } from '@inferencesh/sdk';
704
742
  ```
@@ -4,7 +4,7 @@
4
4
  * Action creators that handle side effects (API calls, streaming).
5
5
  * These are created once per provider instance with access to dispatch.
6
6
  */
7
- import { AgentRunStateWorking, AgentRunStateSubmitted, AgentRunStateInputRequired, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, } from '../types';
7
+ import { AgentRunStateWorking, AgentRunStateSubmitted, AgentRunStateInputRequired, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, ChatMessageStatusReady, ChatMessageStatusFailed, ChatMessageStatusCancelled, } from '../types';
8
8
  import { isChatBusy } from '../utils';
9
9
  import { StreamableManager } from '../http/streamable';
10
10
  import { PollManager } from '../http/poll';
@@ -139,17 +139,19 @@ export function createActions(ctx) {
139
139
  checkTurnEnd(chatData);
140
140
  }
141
141
  });
142
- // Token-by-token streaming state, reset at every assistant-message boundary.
143
- const deltaAccum = createLLMDeltaAccumulator();
142
+ // Token-by-token streaming state, one accumulator per message being
143
+ // streamed. Deltas name their message (DeltaEvent.resource_id), so state
144
+ // never leaks between messages the way a single shared accumulator allowed.
145
+ const deltaAccums = new Map();
144
146
  // Listen for ChatMessage updates
145
147
  manager.addEventListener('chat_messages', (message, fields) => {
146
- // A new assistant message starts a new accumulation. The accumulator is
147
- // cumulative and shared across the whole connection, so without this the
148
- // next message's deltas merge into the previous message's state — and
149
- // because concat with "" is a no-op, a turn that is only tool calls
150
- // would render the previous message's text as its own.
151
- if (message.role === 'assistant' && !getState().messages.some(m => m.id === message.id)) {
152
- deltaAccum.reset();
148
+ // A message that has reached a terminal state will receive no further
149
+ // deltas, so its accumulator is done. This bounds the map by the number
150
+ // of messages streaming at once rather than by chat length.
151
+ if (message.status === ChatMessageStatusReady
152
+ || message.status === ChatMessageStatusFailed
153
+ || message.status === ChatMessageStatusCancelled) {
154
+ deltaAccums.delete(message.id);
153
155
  }
154
156
  updateMessage(message, fields);
155
157
  });
@@ -163,10 +165,23 @@ export function createActions(ctx) {
163
165
  checkTurnEnd({ ...currentChat, active_run: run });
164
166
  });
165
167
  manager.addEventListener('delta', (evt) => {
166
- if (evt && evt.delta) {
167
- deltaAccum.apply(evt.delta);
168
- dispatch({ type: 'DELTA_TOKEN', payload: deltaAccum.toOutput() });
168
+ if (!evt || !evt.delta)
169
+ return;
170
+ // On a chat stream every task is created with an execution edge, so a
171
+ // delta with no resource id means something is wrong upstream — a missing
172
+ // or ambiguous edge, or a failed lookup. Guessing a target there would
173
+ // reintroduce exactly the misattribution this field exists to end, so
174
+ // drop it: the text still lands when the message itself arrives.
175
+ const messageId = evt.resource_id;
176
+ if (!messageId)
177
+ return;
178
+ let accum = deltaAccums.get(messageId);
179
+ if (!accum) {
180
+ accum = createLLMDeltaAccumulator();
181
+ deltaAccums.set(messageId, accum);
169
182
  }
183
+ accum.apply(evt.delta);
184
+ dispatch({ type: 'DELTA_TOKEN', payload: { messageId, output: accum.toOutput() } });
170
185
  });
171
186
  setStreamManager(manager);
172
187
  manager.start();
@@ -85,16 +85,19 @@ export function chatReducer(state, action) {
85
85
  messages: [...state.messages, action.payload].sort((a, b) => a.order - b.order),
86
86
  };
87
87
  case 'DELTA_TOKEN': {
88
- const output = action.payload;
88
+ const { messageId, output } = action.payload;
89
89
  const msgs = state.messages;
90
- const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant');
91
- if (!lastAssistant)
90
+ // Apply to the message the delta names — never to a guessed one. A
91
+ // message we have not received yet (or an unnamed delta) is dropped
92
+ // rather than misattributed; the text still arrives with the message.
93
+ const target = msgs.find(m => m.id === messageId);
94
+ if (!target)
92
95
  return state;
93
- const textBlock = lastAssistant.content?.find(c => c.type === 'text');
96
+ const textBlock = target.content?.find(c => c.type === 'text');
94
97
  const newContent = textBlock
95
- ? lastAssistant.content.map(c => c.type === 'text' ? { ...c, text: output.response } : c)
96
- : [{ type: 'text', text: output.response }, ...lastAssistant.content];
97
- const newMessages = msgs.map(m => m.id === lastAssistant.id ? { ...lastAssistant, content: newContent } : m);
98
+ ? target.content.map(c => c.type === 'text' ? { ...c, text: output.response } : c)
99
+ : [{ type: 'text', text: output.response }, ...target.content];
100
+ const newMessages = msgs.map(m => m.id === target.id ? { ...target, content: newContent } : m);
98
101
  return { ...state, messages: newMessages };
99
102
  }
100
103
  case 'SET_CONNECTION_STATUS':
@@ -220,7 +220,10 @@ export type ChatAction = {
220
220
  payload: ChatMessageDTO;
221
221
  } | {
222
222
  type: 'DELTA_TOKEN';
223
- payload: Record<string, any>;
223
+ payload: {
224
+ messageId: string;
225
+ output: Record<string, any>;
226
+ };
224
227
  } | {
225
228
  type: 'SET_CONNECTION_STATUS';
226
229
  payload: ChatStatus;
@@ -1,7 +1,7 @@
1
1
  import { HttpClient } from '../http/client';
2
2
  import type { Response } from '../http/response';
3
3
  import { FilesAPI } from './files';
4
- import { ChatDTO, ChatMessageDTO, AgentConfigInput as AgentConfig, AgentDTO, AgentVersionDTO, CreateAgentRequest, FileDTO as File, InterruptDTO, CursorListRequest, CursorListResponse } from '../types';
4
+ import { ChatDTO, ChatMessageDTO, LLMDelta, LLMOutput, AgentConfigInput as AgentConfig, AgentDTO, AgentVersionDTO, CreateAgentRequest, FileDTO as File, InterruptDTO, CursorListRequest, CursorListResponse } from '../types';
5
5
  /** Internal tool definition returned by getInternalTools */
6
6
  export interface InternalToolDefinition {
7
7
  id: string;
@@ -20,6 +20,20 @@ export interface AgentOptions {
20
20
  /** Per-chat context variables — resolved in call tool URL templates ({{context.X}}) */
21
21
  context?: Record<string, string>;
22
22
  }
23
+ /**
24
+ * One streamed token batch for a message, with everything received for that
25
+ * message so far. `output.response` is the assistant text as it grows.
26
+ */
27
+ export interface AgentDelta {
28
+ /** The chat message the tokens belong to (normally this turn's assistant message) */
29
+ messageId: string;
30
+ /** This batch alone */
31
+ delta: LLMDelta;
32
+ /** All batches for the message merged, in the shape of the message's final output */
33
+ output: LLMOutput;
34
+ /** Producer sequence number, monotonically increasing per message */
35
+ seq: number;
36
+ }
23
37
  export interface SendMessageOptions {
24
38
  /** File attachments - Blob (will be uploaded) or FileDTO (already uploaded, has uri) */
25
39
  files?: (Blob | File)[];
@@ -27,6 +41,12 @@ export interface SendMessageOptions {
27
41
  onMessage?: (message: ChatMessageDTO) => void;
28
42
  /** Callback for chat updates */
29
43
  onChat?: (chat: ChatDTO) => void;
44
+ /**
45
+ * Callback for token-by-token output while the assistant message is being
46
+ * generated. Streaming mode only: with `stream: false` there are no deltas
47
+ * and the message arrives whole through onMessage.
48
+ */
49
+ onDelta?: (delta: AgentDelta) => void;
30
50
  /** Callback when a client tool needs execution */
31
51
  onToolCall?: (invocation: {
32
52
  id: string;
@@ -1,5 +1,6 @@
1
1
  import { StreamableManager } from '../http/streamable';
2
2
  import { PollManager } from '../http/poll';
3
+ import { createLLMDeltaAccumulator } from '../delta';
3
4
  import { ChatMessageStatusCancelled, ChatMessageStatusFailed, ChatMessageStatusReady, ToolTypeClient, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, } from '../types';
4
5
  import { isChatBusy } from '../utils';
5
6
  const terminalMessageStatuses = new Set([ChatMessageStatusReady, ChatMessageStatusFailed, ChatMessageStatusCancelled]);
@@ -59,7 +60,7 @@ export class Agent {
59
60
  async sendMessage(text, options = {}) {
60
61
  this.dispatchedToolCalls.clear();
61
62
  const isTemplate = typeof this.config === 'string';
62
- const hasCallbacks = !!(options.onMessage || options.onChat || options.onToolCall);
63
+ const hasCallbacks = !!(options.onMessage || options.onChat || options.onToolCall || options.onDelta);
63
64
  // Process files - either already uploaded (FileDTO with uri) or needs upload (Blob)
64
65
  let imageUris;
65
66
  let fileUris;
@@ -227,7 +228,30 @@ export class Agent {
227
228
  else
228
229
  idlePending = false;
229
230
  });
231
+ // One accumulator per message being streamed, keyed by the message id
232
+ // the delta names, so a tool-call-only turn never shows the previous
233
+ // message's text. Same attribution rule as the React hooks.
234
+ const deltaAccums = new Map();
235
+ this.stream.addEventListener('delta', (evt) => {
236
+ if (!options.onDelta || !evt?.delta || !evt.resource_id)
237
+ return;
238
+ let accum = deltaAccums.get(evt.resource_id);
239
+ if (!accum) {
240
+ accum = createLLMDeltaAccumulator();
241
+ deltaAccums.set(evt.resource_id, accum);
242
+ }
243
+ accum.apply(evt.delta);
244
+ options.onDelta({
245
+ messageId: evt.resource_id,
246
+ delta: evt.delta,
247
+ output: accum.toOutput(),
248
+ seq: evt.seq,
249
+ });
250
+ });
230
251
  this.stream.addEventListener('chat_messages', (message) => {
252
+ // A terminal message receives no further deltas.
253
+ if (terminalMessageStatuses.has(message.status))
254
+ deltaAccums.delete(message.id);
231
255
  gate.observeMessage(message);
232
256
  options.onMessage?.(message);
233
257
  if (idlePending && gate.settled)
@@ -1,6 +1,6 @@
1
1
  import { HttpClient } from '../http/client';
2
2
  import type { Response } from '../http/response';
3
- import { IntegrationDTO, IntegrationConfigDTO, IntegrationConnectRequest, IntegrationConnectResponse, CursorListRequest, CursorListResponse } from '../types';
3
+ import { CredentialDTO, CredentialConfigDTO, CredentialConnectRequest, CredentialConnectResponse, CursorListRequest, CursorListResponse } from '../types';
4
4
  /**
5
5
  * Integrations API
6
6
  */
@@ -10,15 +10,15 @@ export declare class IntegrationsAPI {
10
10
  /**
11
11
  * List integrations with cursor-based pagination
12
12
  */
13
- list(params?: Partial<CursorListRequest>): Promise<Response<CursorListResponse<IntegrationDTO>>>;
13
+ list(params?: Partial<CursorListRequest>): Promise<Response<CursorListResponse<CredentialDTO>>>;
14
14
  /**
15
15
  * Get available integrations
16
16
  */
17
- listAvailable(): Promise<Response<IntegrationConfigDTO[]>>;
17
+ listAvailable(): Promise<Response<CredentialConfigDTO[]>>;
18
18
  /**
19
19
  * Get integration configs
20
20
  */
21
- getConfigs(): Promise<Response<IntegrationConfigDTO[]>>;
21
+ getConfigs(): Promise<Response<CredentialConfigDTO[]>>;
22
22
  /**
23
23
  * Get capabilities
24
24
  */
@@ -30,11 +30,11 @@ export declare class IntegrationsAPI {
30
30
  /**
31
31
  * Connect an integration
32
32
  */
33
- connect(data: IntegrationConnectRequest): Promise<Response<IntegrationConnectResponse>>;
33
+ connect(data: CredentialConnectRequest): Promise<Response<CredentialConnectResponse>>;
34
34
  /**
35
35
  * Get an integration by provider key
36
36
  */
37
- get(provider: string): Promise<Response<IntegrationDTO>>;
37
+ get(provider: string): Promise<Response<CredentialDTO>>;
38
38
  /**
39
39
  * Disconnect an integration
40
40
  */
@@ -0,0 +1,44 @@
1
+ import { HttpClient } from '../http/client';
2
+ import type { Response } from '../http/response';
3
+ import { LiveSession, type LiveHandlers, type WebSocketConstructor } from '../live/session';
4
+ import { CursorListRequest, CursorListResponse, SocketAccess, SocketDTO, TaskDTO as Task } from '../types';
5
+ import type { TasksAPI } from './tasks';
6
+ /** What identifies the socket to open: the run response (which carries the access), a task, or a task id. */
7
+ export type SocketTarget = (Pick<Task, 'id' | 'status'> & {
8
+ socket?: SocketAccess;
9
+ }) | string;
10
+ export interface OpenSocketOptions {
11
+ /**
12
+ * Follow the task while waiting for the app, and end the session if the
13
+ * task ends first (default: true). Off, a task that fails before its
14
+ * worker dials leaves the session waiting until the relay's pair timeout.
15
+ */
16
+ watchTask?: boolean;
17
+ /** The WebSocket to dial with; defaults to the runtime's global one. */
18
+ webSocket?: WebSocketConstructor;
19
+ }
20
+ /**
21
+ * Sockets API: the duplex connection of a stream task.
22
+ *
23
+ * A stream function keeps a socket open with its caller for the life of the
24
+ * task. The run response carries the caller's end (`task.socket`); `open`
25
+ * dials it and gives back a LiveSession.
26
+ */
27
+ export declare class SocketsAPI {
28
+ private readonly http;
29
+ private readonly tasks;
30
+ constructor(http: HttpClient, tasks: TasksAPI);
31
+ get(id: string): Promise<Response<SocketDTO>>;
32
+ list(params?: Partial<CursorListRequest>): Promise<Response<CursorListResponse<SocketDTO>>>;
33
+ /** The task's socket, or null when it has none (not a stream function). */
34
+ forTask(taskId: string): Promise<SocketDTO | null>;
35
+ /** A fresh credential for the caller's end, e.g. after a reload or to redial. */
36
+ access(id: string): Promise<Response<SocketAccess>>;
37
+ delete(id: string): Promise<Response<void>>;
38
+ /**
39
+ * Dials the caller's end of a stream task's socket. The session is
40
+ * `waiting` until the app's first frame, then `live`; see LiveSession.
41
+ */
42
+ open(target: SocketTarget, handlers?: LiveHandlers, options?: OpenSocketOptions): Promise<LiveSession>;
43
+ }
44
+ export declare function createSocketsAPI(http: HttpClient, tasks: TasksAPI): SocketsAPI;
@@ -0,0 +1,61 @@
1
+ import { LiveSession } from '../live/session';
2
+ import { OpEqual } from '../types';
3
+ /**
4
+ * Sockets API: the duplex connection of a stream task.
5
+ *
6
+ * A stream function keeps a socket open with its caller for the life of the
7
+ * task. The run response carries the caller's end (`task.socket`); `open`
8
+ * dials it and gives back a LiveSession.
9
+ */
10
+ export class SocketsAPI {
11
+ constructor(http, tasks) {
12
+ this.http = http;
13
+ this.tasks = tasks;
14
+ }
15
+ async get(id) {
16
+ return this.http.request('get', `/sockets/${id}`);
17
+ }
18
+ async list(params) {
19
+ return this.http.request('post', '/sockets/list', { data: params });
20
+ }
21
+ /** The task's socket, or null when it has none (not a stream function). */
22
+ async forTask(taskId) {
23
+ const res = await this.list({ limit: 1, filters: [{ field: 'task_id', operator: OpEqual, value: taskId }] });
24
+ return res.data?.items?.[0] ?? null;
25
+ }
26
+ /** A fresh credential for the caller's end, e.g. after a reload or to redial. */
27
+ async access(id) {
28
+ return this.http.request('post', `/sockets/${id}/access`);
29
+ }
30
+ async delete(id) {
31
+ return this.http.request('delete', `/sockets/${id}`);
32
+ }
33
+ /**
34
+ * Dials the caller's end of a stream task's socket. The session is
35
+ * `waiting` until the app's first frame, then `live`; see LiveSession.
36
+ */
37
+ async open(target, handlers = {}, options = {}) {
38
+ const task = typeof target === 'string' ? (await this.tasks.get(target)).data : target;
39
+ let access = typeof target === 'string' ? undefined : target.socket;
40
+ let socketId = access?.id;
41
+ if (!access) {
42
+ const socket = await this.forTask(task.id);
43
+ if (!socket)
44
+ throw new Error(`task ${task.id} has no socket: is it a stream function?`);
45
+ socketId = socket.id;
46
+ access = (await this.access(socket.id)).data;
47
+ }
48
+ const session = new LiveSession({
49
+ access,
50
+ handlers,
51
+ renew: async () => (await this.access(socketId)).data,
52
+ task: options.watchTask === false ? undefined : this.tasks.watch(task),
53
+ webSocket: options.webSocket,
54
+ });
55
+ session.connect();
56
+ return session;
57
+ }
58
+ }
59
+ export function createSocketsAPI(http, tasks) {
60
+ return new SocketsAPI(http, tasks);
61
+ }
@@ -1,13 +1,12 @@
1
1
  import { HttpClient } from '../http/client';
2
2
  import type { Response } from '../http/response';
3
3
  import { TaskDTO as Task, TaskLogsDTO, TaskTimingsDTO, ApiAppRunRequest, CursorListRequest, CursorListResponse } from '../types';
4
- export interface RunOptions {
4
+ /** How to follow a task while it runs; see TasksAPI.watch. */
5
+ export interface WatchOptions {
5
6
  /** Callback for real-time status updates */
6
7
  onUpdate?: (update: Task) => void;
7
8
  /** Callback for partial updates with list of changed fields */
8
9
  onPartialUpdate?: (update: Task, fields: string[]) => void;
9
- /** Wait for task completion (default: true) */
10
- wait?: boolean;
11
10
  /** Maximum retry attempts when using polling mode (stream: false). Default: 5 */
12
11
  maxReconnects?: number;
13
12
  /** Use SSE streaming (true) or polling (false). Overrides client default. */
@@ -17,6 +16,15 @@ export interface RunOptions {
17
16
  /** Callback for streaming delta events (token-by-token updates) */
18
17
  onDelta?: (delta: Record<string, any>, seq: number) => void;
19
18
  }
19
+ export interface RunOptions extends WatchOptions {
20
+ /** Wait for task completion (default: true) */
21
+ wait?: boolean;
22
+ }
23
+ /** A task being followed: `done` settles when it ends, `stop` ends the watch early. */
24
+ export interface TaskWatch {
25
+ done: Promise<Task>;
26
+ stop(): void;
27
+ }
20
28
  /**
21
29
  * Tasks API
22
30
  */
@@ -55,8 +63,15 @@ export declare class TasksAPI {
55
63
  * Run a task and optionally wait for completion
56
64
  */
57
65
  run(params: ApiAppRunRequest, processedInput: unknown, options?: RunOptions): Promise<Task>;
66
+ /**
67
+ * Follows a task until it ends. `done` resolves with the task when it
68
+ * completes and rejects when it fails or is cancelled; `stop` ends the
69
+ * watch early and leaves `done` pending.
70
+ */
71
+ watch(task: Pick<Task, 'id' | 'status'>, options?: WatchOptions): TaskWatch;
72
+ private watchStream;
58
73
  /** Poll GET /tasks/{id}/status until terminal, full-fetch on status change. */
59
- private pollUntilTerminal;
74
+ private watchPoll;
60
75
  /**
61
76
  * Update task visibility
62
77
  */
package/dist/api/tasks.js CHANGED
@@ -69,7 +69,7 @@ export class TasksAPI {
69
69
  * Run a task and optionally wait for completion
70
70
  */
71
71
  async run(params, processedInput, options = {}) {
72
- const { onUpdate, onPartialUpdate, onDelta, wait = true, } = options;
72
+ const { wait = true } = options;
73
73
  const resp = await this.http.request('post', '/apps/run', {
74
74
  data: {
75
75
  ...params,
@@ -81,16 +81,39 @@ export class TasksAPI {
81
81
  if (!wait) {
82
82
  return stripTask(task);
83
83
  }
84
+ return this.watch(task, options).done;
85
+ }
86
+ /**
87
+ * Follows a task until it ends. `done` resolves with the task when it
88
+ * completes and rejects when it fails or is cancelled; `stop` ends the
89
+ * watch early and leaves `done` pending.
90
+ */
91
+ watch(task, options = {}) {
84
92
  const useStream = options.stream ?? this.http.getStreamDefault();
85
- if (!useStream) {
86
- return this.pollUntilTerminal(task, options);
87
- }
88
- // Wait for completion with optional updates via NDJSON streaming
93
+ return useStream ? this.watchStream(task, options) : this.watchPoll(task, options);
94
+ }
95
+ watchStream(task, options) {
96
+ const { onUpdate, onPartialUpdate, onDelta } = options;
89
97
  // Accumulate state across partial updates to preserve fields like session_id
90
98
  let accumulatedTask = { ...task };
91
99
  const { url, headers, credentials } = this.http.getStreamableConfig(`/tasks/${task.id}/stream`);
92
- return new Promise((resolve, reject) => {
93
- const streamManager = new StreamableManager({
100
+ let streamManager;
101
+ const done = new Promise((resolve, reject) => {
102
+ const settle = (data, stripped) => {
103
+ if (parseStatus(data.status) === TaskStatusCompleted) {
104
+ streamManager.stop();
105
+ resolve(stripped);
106
+ }
107
+ else if (parseStatus(data.status) === TaskStatusFailed) {
108
+ streamManager.stop();
109
+ reject(new Error(data.error || 'task failed'));
110
+ }
111
+ else if (parseStatus(data.status) === TaskStatusCancelled) {
112
+ streamManager.stop();
113
+ reject(new Error('task cancelled'));
114
+ }
115
+ };
116
+ streamManager = new StreamableManager({
94
117
  url,
95
118
  headers,
96
119
  credentials,
@@ -100,36 +123,14 @@ export class TasksAPI {
100
123
  accumulatedTask = { ...accumulatedTask, ...data };
101
124
  const stripped = stripTask(accumulatedTask);
102
125
  onUpdate?.(stripped);
103
- if (parseStatus(data.status) === TaskStatusCompleted) {
104
- streamManager.stop();
105
- resolve(stripped);
106
- }
107
- else if (parseStatus(data.status) === TaskStatusFailed) {
108
- streamManager.stop();
109
- reject(new Error(data.error || 'task failed'));
110
- }
111
- else if (parseStatus(data.status) === TaskStatusCancelled) {
112
- streamManager.stop();
113
- reject(new Error('task cancelled'));
114
- }
126
+ settle(data, stripped);
115
127
  },
116
128
  onPartialData: (data, fields) => {
117
129
  // Merge partial update, preserving fields not in this update
118
130
  accumulatedTask = { ...accumulatedTask, ...data };
119
131
  const stripped = stripTask(accumulatedTask);
120
132
  onPartialUpdate?.(stripped, fields);
121
- if (parseStatus(data.status) === TaskStatusCompleted) {
122
- streamManager.stop();
123
- resolve(stripped);
124
- }
125
- else if (parseStatus(data.status) === TaskStatusFailed) {
126
- streamManager.stop();
127
- reject(new Error(data.error || 'task failed'));
128
- }
129
- else if (parseStatus(data.status) === TaskStatusCancelled) {
130
- streamManager.stop();
131
- reject(new Error('task cancelled'));
132
- }
133
+ settle(data, stripped);
133
134
  },
134
135
  onError: (error) => {
135
136
  reject(error);
@@ -138,14 +139,16 @@ export class TasksAPI {
138
139
  });
139
140
  streamManager.start();
140
141
  });
142
+ return { done, stop: () => streamManager.stop() };
141
143
  }
142
144
  /** Poll GET /tasks/{id}/status until terminal, full-fetch on status change. */
143
- pollUntilTerminal(task, options) {
145
+ watchPoll(task, options) {
144
146
  const { onUpdate, maxReconnects = 5 } = options;
145
147
  const intervalMs = options.pollIntervalMs ?? this.http.getPollIntervalMs();
146
148
  let prevStatus = task.status;
147
- return new Promise((resolve, reject) => {
148
- const poller = new PollManager({
149
+ let poller;
150
+ const done = new Promise((resolve, reject) => {
151
+ poller = new PollManager({
149
152
  pollFunction: async () => {
150
153
  const resp = await this.http.request('get', `/tasks/${task.id}/status`);
151
154
  return resp.data;
@@ -187,6 +190,7 @@ export class TasksAPI {
187
190
  });
188
191
  poller.start();
189
192
  });
193
+ return { done, stop: () => poller.stop() };
190
194
  }
191
195
  /**
192
196
  * Update task visibility