@inferencesh/sdk 0.27.0 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.29.0] - 2026-10-17
11
+
12
+ ### Removed
13
+ - `CredentialConfigDTO.provider_info` is gone: read `company` (INF-1079). The API stopped sending it in api-v1313.
14
+
15
+ ### Changed
16
+ - `CredentialConfigDTO.auth_scheme_id` is `auth_provider_id`, matching the API (INF-1079).
17
+ - Generated types follow the API: engine release targets (`client.engines.updateBinary` takes a release target, `releases()` lists channel heads), `EngineDTO.capabilities` (`EngineCapability`, `EngineCapabilities`), `ChatMessageDTO.compaction_meta`.
18
+
19
+ ### Fixed
20
+ - Partial chat and run stream updates merge instead of replacing, and streamed text never blinks back to an older copy.
21
+ - A read that got no response is sent again, twice.
22
+
23
+ ## [0.28.0] - 2026-10-15
24
+
25
+ ### Changed
26
+ - Types regenerated for the credentials vocabulary (INF-1079): `AuthSchemeDTO`, `AuthSchemeCreateRequest`, `AuthSchemeUpdateRequest`, `AuthSchemeHooksDTO`, `AuthSchemeSeedResult` are now `AuthProviderDTO`, `AuthProviderCreateRequest`, `AuthProviderUpdateRequest`, `AuthProviderHooksDTO`, `AuthProviderSeedResult`; `AuthSchemeSpec` and its `AuthScheme*` enums are `AuthMethod` and `AuthMethod*`; `ProviderDTO`, `ProviderSeed`, `ProviderSummary`, `ProviderTreeDTO`, `ProviderUpsertRequest`, `ProviderCategory`, `ProviderStatus` are `Company*`. `CredentialConfigDTO.company` is the company (`provider_info` still carries the same value, deprecated). Wire formats are unchanged.
27
+ - `pollUntilIdle` rejects once and stops polling when the run fails; `sendMessage({ stream: false })` no longer hangs forever (99075b7).
28
+ - Test mocks tidied (70cce44); dependabot overrides and `allowBuilds` in the pnpm workspace (75be627, 54b7ce1).
29
+
10
30
  ## [0.27.0] - 2026-10-13
11
31
 
12
32
  ### Changed
@@ -12,6 +12,7 @@ import { createLLMDeltaAccumulator } from '../delta.js';
12
12
  import { isAdHocConfig, extractClientToolHandlers } from './types.js';
13
13
  import * as api from './api.js';
14
14
  import { buildMCPInputResult } from './mcp-input.js';
15
+ import { applyChatUpdate } from './reducer.js';
15
16
  import { isInferenceError } from '../http/errors.js';
16
17
  // =============================================================================
17
18
  // Action Creators
@@ -226,11 +227,16 @@ export function createActions(ctx) {
226
227
  },
227
228
  });
228
229
  // Listen for Chat object updates (status changes only — don't replace messages)
229
- manager.addEventListener('chats', (chatData) => {
230
- dispatch({ type: 'UPDATE_CHAT', payload: chatData });
231
- if (chatData) {
232
- callbacks.onStatusChange?.(isChatBusy(chatData) ? 'streaming' : 'idle');
233
- checkTurnEnd(chatData);
230
+ manager.addEventListener('chats', (chatData, fields) => {
231
+ if (!chatData)
232
+ return;
233
+ dispatch({ type: 'UPDATE_CHAT', payload: chatData, fields });
234
+ // A partial update (a plan step writing agent_data) says nothing about
235
+ // the turn on its own: judge the chat it merges into.
236
+ const chat = applyChatUpdate(getState().chat, chatData, fields);
237
+ if (chat) {
238
+ callbacks.onStatusChange?.(isChatBusy(chat) ? 'streaming' : 'idle');
239
+ checkTurnEnd(chat);
234
240
  }
235
241
  });
236
242
  // Listen for ChatMessage updates
@@ -244,8 +250,11 @@ export function createActions(ctx) {
244
250
  updateMessage(message, fields);
245
251
  });
246
252
  // Listen for AgentRun updates (state transitions, output)
247
- manager.addEventListener('agent_runs', (run) => {
248
- dispatch({ type: 'UPDATE_ACTIVE_RUN', payload: run });
253
+ manager.addEventListener('agent_runs', (run, fields) => {
254
+ dispatch({ type: 'UPDATE_ACTIVE_RUN', payload: run, fields });
255
+ // A partial update (the run's output) carries no state.
256
+ if (fields)
257
+ return;
249
258
  callbacks.onStatusChange?.(isRunWorking(run.state) || isRunInterrupted(run.state) ? 'streaming' : 'idle');
250
259
  const currentChat = getState().chat;
251
260
  if (currentChat)
@@ -3,6 +3,12 @@
3
3
  *
4
4
  * Pure reducer for managing agent chat state.
5
5
  */
6
+ import type { ChatDTO } from '../types.js';
6
7
  import type { AgentChatState, ChatAction } from './types.js';
8
+ /**
9
+ * The chat after a chats event: replaced by a full one, merged with a partial
10
+ * one. A partial update to another chat (or before any chat) is dropped.
11
+ */
12
+ export declare function applyChatUpdate(existing: ChatDTO | null, update: ChatDTO, fields?: string[]): ChatDTO | null;
7
13
  export declare const initialState: AgentChatState;
8
14
  export declare function chatReducer(state: AgentChatState, action: ChatAction): AgentChatState;
@@ -4,7 +4,7 @@
4
4
  * Pure reducer for managing agent chat state.
5
5
  */
6
6
  import { ChatStatusBusy, ChatStatusAwaitingInput, ChatStatusIdle } from '../types.js';
7
- import { isRunInterrupted, isRunWorking } from '../utils.js';
7
+ import { isMessageTerminal, isRunInterrupted, isRunWorking } from '../utils.js';
8
8
  function deriveChatStatus(run) {
9
9
  if (!run)
10
10
  return ChatStatusIdle;
@@ -14,6 +14,38 @@ function deriveChatStatus(run) {
14
14
  return ChatStatusAwaitingInput;
15
15
  return ChatStatusIdle;
16
16
  }
17
+ /**
18
+ * A partial stream update (`fields` set) carries only the fields it names, so
19
+ * it merges into the object it updates instead of replacing it: a chat update
20
+ * naming only agent_data (a plan step) keeps the chat's status and active run.
21
+ * A named field missing from the payload was cleared (omitempty).
22
+ */
23
+ function mergeFields(existing, update, fields) {
24
+ const merged = { ...existing };
25
+ for (const field of fields)
26
+ merged[field] = update[field];
27
+ return merged;
28
+ }
29
+ /**
30
+ * The chat after a chats event: replaced by a full one, merged with a partial
31
+ * one. A partial update to another chat (or before any chat) is dropped.
32
+ */
33
+ export function applyChatUpdate(existing, update, fields) {
34
+ if (!fields)
35
+ return update;
36
+ if (!existing || existing.id !== update.id)
37
+ return existing;
38
+ const merged = mergeFields(existing, update, fields);
39
+ // The projector's update names active_run_id, which ChatDTO does not
40
+ // declare. The run object belongs to that id: a new or cleared active run
41
+ // drops it until the run's own event arrives.
42
+ if (fields.includes('active_run_id')) {
43
+ const runId = update.active_run_id;
44
+ if (merged.active_run && merged.active_run.id !== runId)
45
+ merged.active_run = undefined;
46
+ }
47
+ return merged;
48
+ }
17
49
  /**
18
50
  * Every message enters state through here. The API serializes a message with
19
51
  * no content as `content: null` (a nil Go slice), and a partial stream update
@@ -27,6 +59,28 @@ function normalizeMessage(message, existing) {
27
59
  return message;
28
60
  return { ...message, content: existing?.content ?? [] };
29
61
  }
62
+ /**
63
+ * Streamed text only grows until the message is done. Deltas build the text
64
+ * locally, while a message update carries the server's saved copy, which can
65
+ * lag behind them (often still empty). Taking that copy as-is blanks the text
66
+ * until the next delta, so while the message is still generating, a text
67
+ * that is an earlier version of what is shown keeps what is shown. Once the
68
+ * message is terminal the server's copy is final and wins.
69
+ */
70
+ function keepStreamedText(updated, existing) {
71
+ if (isMessageTerminal(updated.status))
72
+ return updated;
73
+ const shown = existing.content.find((c) => c.type === 'text')?.text;
74
+ if (!shown)
75
+ return updated;
76
+ const incoming = updated.content.find((c) => c.type === 'text');
77
+ if (incoming?.text && !shown.startsWith(incoming.text))
78
+ return updated;
79
+ const content = incoming
80
+ ? updated.content.map((c) => (c === incoming ? { ...c, text: shown } : c))
81
+ : [{ type: 'text', text: shown }, ...updated.content];
82
+ return { ...updated, content };
83
+ }
30
84
  const normalizeMessages = (messages) => messages.map((m) => normalizeMessage(m));
31
85
  // =============================================================================
32
86
  // Initial State
@@ -59,10 +113,10 @@ export function chatReducer(state, action) {
59
113
  }
60
114
  case 'UPDATE_CHAT': {
61
115
  // Update chat metadata without replacing messages
62
- const chat = action.payload;
63
- if (!chat)
116
+ if (!action.payload)
64
117
  return state;
65
- return { ...state, chat };
118
+ const chat = applyChatUpdate(state.chat, action.payload, action.fields);
119
+ return chat === state.chat ? state : { ...state, chat };
66
120
  }
67
121
  // The settings and set-agent endpoints answer with only what they wrote.
68
122
  // Merge it into the chat it belongs to; a chat switched away from in the
@@ -104,7 +158,15 @@ export function chatReducer(state, action) {
104
158
  case 'UPDATE_ACTIVE_RUN': {
105
159
  if (!state.chat)
106
160
  return state;
107
- return { ...state, chat: { ...state.chat, active_run: action.payload, status: deriveChatStatus(action.payload) } };
161
+ let run = action.payload;
162
+ if (action.fields) {
163
+ // A partial update (the run's output) needs the run it belongs to.
164
+ const existing = state.chat.active_run;
165
+ if (!existing || existing.id !== run.id)
166
+ return state;
167
+ run = mergeFields(existing, run, action.fields);
168
+ }
169
+ return { ...state, chat: { ...state.chat, active_run: run, status: deriveChatStatus(run) } };
108
170
  }
109
171
  case 'SET_MESSAGES':
110
172
  return { ...state, messages: normalizeMessages(action.payload) };
@@ -120,9 +182,7 @@ export function chatReducer(state, action) {
120
182
  const existingIndex = state.messages.findIndex((m) => m.id === message.id);
121
183
  if (existingIndex !== -1) {
122
184
  const existing = state.messages[existingIndex];
123
- const updated = action.partial
124
- ? normalizeMessage({ ...existing, ...message }, existing)
125
- : normalizeMessage(message);
185
+ const updated = keepStreamedText(action.partial ? normalizeMessage({ ...existing, ...message }, existing) : normalizeMessage(message), existing);
126
186
  if (existing === updated)
127
187
  return state;
128
188
  const newMessages = [...state.messages];
@@ -148,6 +208,10 @@ export function chatReducer(state, action) {
148
208
  if (!target)
149
209
  return state;
150
210
  const textBlock = target.content.find(c => c.type === 'text');
211
+ // Deltas from a stream joined mid-message start partway; the message
212
+ // update already showed more, so keep it until they catch up.
213
+ if (textBlock?.text && textBlock.text.startsWith(output.response ?? ''))
214
+ return state;
151
215
  const newContent = textBlock
152
216
  ? target.content.map(c => c.type === 'text' ? { ...c, text: output.response } : c)
153
217
  : [{ type: 'text', text: output.response }, ...target.content];
@@ -261,6 +261,7 @@ export type ChatAction = {
261
261
  } | {
262
262
  type: 'UPDATE_CHAT';
263
263
  payload: ChatDTO | null;
264
+ fields?: string[];
264
265
  } | {
265
266
  type: 'MERGE_CHAT_SETTINGS';
266
267
  payload: ChatSettingsDTO;
@@ -270,6 +271,7 @@ export type ChatAction = {
270
271
  } | {
271
272
  type: 'UPDATE_ACTIVE_RUN';
272
273
  payload: AgentRunDTO;
274
+ fields?: string[];
273
275
  } | {
274
276
  type: 'SET_MESSAGES';
275
277
  payload: ChatMessageDTO[];
@@ -330,9 +330,9 @@ export class Agent {
330
330
  // fetched chat never carries chat_messages, so their absence cannot tell
331
331
  // the two apart.
332
332
  const unchanged = {};
333
- return new Promise((resolve) => {
333
+ return new Promise((resolve, reject) => {
334
334
  this.poller?.stop();
335
- this.poller = new PollManager({
335
+ const poller = new PollManager({
336
336
  pollFunction: async () => {
337
337
  // Lightweight status check first
338
338
  const statusResp = await this.http.request('get', `/chats/${this.chatId}/status`);
@@ -391,8 +391,19 @@ export class Agent {
391
391
  onError: (error) => {
392
392
  console.warn('[Agent] Poll error:', error);
393
393
  },
394
+ // PollManager gave up after maxRetries consecutive errors: it has
395
+ // already stopped polling, so fail the turn once. A caller stop
396
+ // (disconnect, abort, a newer turn) passes no error.
397
+ onStop: (error) => {
398
+ if (!error)
399
+ return;
400
+ if (this.poller === poller)
401
+ this.poller = null;
402
+ reject(error);
403
+ },
394
404
  });
395
- this.poller.start();
405
+ this.poller = poller;
406
+ poller.start();
396
407
  });
397
408
  }
398
409
  }
@@ -1,7 +1,7 @@
1
1
  import { HttpClient } from '../http/client.js';
2
2
  import type { Response } from '../http/response.js';
3
3
  import type { EngineUpdateBody } from './write-bodies.js';
4
- import { EngineDTO as Engine, CursorListRequest, CursorListResponse } from '../types.js';
4
+ import { EngineDTO as Engine, EngineReleasesResponse, CursorListRequest, CursorListResponse } from '../types.js';
5
5
  /**
6
6
  * Engines API
7
7
  */
@@ -65,8 +65,16 @@ export declare class EnginesAPI {
65
65
  */
66
66
  drain(engineId: string): Promise<Response<void>>;
67
67
  /**
68
- * Update an engine binary (drain + restart with new version)
68
+ * Update an engine binary: it drains, installs the release and restarts.
69
+ * `version` is "stable" (the default: newest stable, lifting any pin),
70
+ * "canary" (the current canary build, pinned), or a version such as
71
+ * v1.9.3 (pinned). A pinned engine stays on its version until sent
72
+ * "stable" again.
69
73
  */
70
- updateBinary(engineId: string): Promise<Response<void>>;
74
+ updateBinary(engineId: string, version?: string): Promise<Response<void>>;
75
+ /**
76
+ * The head of each engine release channel (stable, canary) on dist.inference.sh.
77
+ */
78
+ releases(): Promise<Response<EngineReleasesResponse>>;
71
79
  }
72
80
  export declare function createEnginesAPI(http: HttpClient): EnginesAPI;
@@ -85,10 +85,21 @@ export class EnginesAPI {
85
85
  return this.http.request('post', `/engines/${engineId}/drain`);
86
86
  }
87
87
  /**
88
- * Update an engine binary (drain + restart with new version)
88
+ * Update an engine binary: it drains, installs the release and restarts.
89
+ * `version` is "stable" (the default: newest stable, lifting any pin),
90
+ * "canary" (the current canary build, pinned), or a version such as
91
+ * v1.9.3 (pinned). A pinned engine stays on its version until sent
92
+ * "stable" again.
89
93
  */
90
- async updateBinary(engineId) {
91
- return this.http.request('post', `/engines/${engineId}/update`);
94
+ async updateBinary(engineId, version) {
95
+ const body = version ? { version } : {};
96
+ return this.http.request('post', `/engines/${engineId}/update`, { data: body });
97
+ }
98
+ /**
99
+ * The head of each engine release channel (stable, canary) on dist.inference.sh.
100
+ */
101
+ async releases() {
102
+ return this.http.request('get', '/engines/releases');
92
103
  }
93
104
  }
94
105
  export function createEnginesAPI(http) {
@@ -12,7 +12,7 @@
12
12
  * chats, engines and teams send PATCH; apps.patch and flows.patch are the
13
13
  * partial siblings of the POST updates that also edit a version.
14
14
  */
15
- import type { AgentConfigInput, AgentImages, AppDTO, ChatDTO, CredentialRequirements, EngineDTO, FlowDTO, FlowVersionDTO, KnowledgeDTO, ProjectDTO, TeamDTO, Visibility } from '../types.js';
15
+ import type { AgentConfigInput, ResourceImages, AppDTO, ChatDTO, CredentialRequirements, EngineDTO, FlowDTO, FlowVersionDTO, KnowledgeDTO, ProjectDTO, TeamDTO, Visibility } from '../types.js';
16
16
  /**
17
17
  * POST /agents/{id}. Visibility and images, and a new version when
18
18
  * `version` is set. Name, title, harness, placement: set at creation.
@@ -20,7 +20,7 @@ import type { AgentConfigInput, AgentImages, AppDTO, ChatDTO, CredentialRequirem
20
20
  */
21
21
  export interface AgentUpdateBody {
22
22
  visibility?: Visibility;
23
- images?: AgentImages;
23
+ images?: ResourceImages;
24
24
  version?: AgentConfigInput;
25
25
  }
26
26
  /**
@@ -1,6 +1,40 @@
1
1
  import { InferenceError, RequirementsNotMetException } from './errors.js';
2
2
  import { EventSource } from 'eventsource';
3
3
  import { SDK_VERSION } from '../version.js';
4
+ /** How long to wait before each new try of a read that got no response. */
5
+ const NETWORK_RETRY_DELAYS_MS = [300, 1000];
6
+ /**
7
+ * fetch, sending a GET or HEAD that got no response again after each of
8
+ * NETWORK_RETRY_DELAYS_MS: a dropped connection or a network change fails a
9
+ * read that would succeed a moment later, and a read changes nothing on the
10
+ * server, so sending it again is safe. fetch rejects with a TypeError only
11
+ * when no response came; a response of any status, an abort, or a request
12
+ * of another method is the caller's at once.
13
+ */
14
+ function fetchRetryingReads(input, init, attempt = 0) {
15
+ const method = (init.method ?? 'GET').toUpperCase();
16
+ const read = method === 'GET' || method === 'HEAD';
17
+ const sent = fetch(input, init);
18
+ if (!read)
19
+ return sent;
20
+ return sent.catch(async (error) => {
21
+ const delay = NETWORK_RETRY_DELAYS_MS[attempt];
22
+ if (delay === undefined || !(error instanceof TypeError) || init.signal?.aborted)
23
+ throw error;
24
+ await wait(delay, init.signal);
25
+ return fetchRetryingReads(input, init, attempt + 1);
26
+ });
27
+ }
28
+ /** Resolves after ms, or rejects with the abort when signal aborts first. */
29
+ function wait(ms, signal) {
30
+ return new Promise((resolve, reject) => {
31
+ const timer = setTimeout(resolve, ms);
32
+ signal?.addEventListener('abort', () => {
33
+ clearTimeout(timer);
34
+ reject(signal.reason);
35
+ }, { once: true });
36
+ });
37
+ }
4
38
  /**
5
39
  * Low-level HTTP client for inference.sh API
6
40
  *
@@ -127,7 +161,7 @@ export class HttpClient {
127
161
  * (`refused: 'return'`), handed back with its body unread.
128
162
  */
129
163
  async fetchHandled(url, targetUrl, init, refused) {
130
- const send = (token) => fetch(url, {
164
+ const send = (token) => fetchRetryingReads(url, {
131
165
  ...init,
132
166
  headers: { ...init.headers, ...this.requestHeaders(targetUrl, token) },
133
167
  credentials: this.credentials,
@@ -213,7 +247,7 @@ export class HttpClient {
213
247
  if (options.data) {
214
248
  fetchOptions.body = JSON.stringify(options.data);
215
249
  }
216
- const response = await fetch(fetchUrl, fetchOptions);
250
+ const response = await fetchRetryingReads(fetchUrl, fetchOptions);
217
251
  const responseText = await response.text();
218
252
  let data = null;
219
253
  try {
@@ -19,8 +19,12 @@ export interface PollManagerOptions<T> {
19
19
  onError?: (error: Error) => void;
20
20
  /** Called when polling starts */
21
21
  onStart?: () => void;
22
- /** Called when polling stops (intentionally or after max retries) */
23
- onStop?: () => void;
22
+ /**
23
+ * Called once when polling stops. `error` is the last poll error when the
24
+ * manager gave up after `maxRetries` consecutive errors; it is undefined
25
+ * when the caller stopped it.
26
+ */
27
+ onStop?: (error?: Error) => void;
24
28
  }
25
29
  export declare class PollManager<T> {
26
30
  private options;
@@ -34,6 +38,7 @@ export declare class PollManager<T> {
34
38
  start(): void;
35
39
  /** Stop polling and clean up. */
36
40
  stop(): void;
41
+ private halt;
37
42
  private cleanup;
38
43
  private poll;
39
44
  }
package/dist/http/poll.js CHANGED
@@ -30,11 +30,14 @@ export class PollManager {
30
30
  }
31
31
  /** Stop polling and clean up. */
32
32
  stop() {
33
+ this.halt();
34
+ }
35
+ halt(error) {
33
36
  if (this.isStopped)
34
37
  return;
35
38
  this.isStopped = true;
36
39
  this.cleanup();
37
- this.options.onStop?.();
40
+ this.options.onStop?.(error);
38
41
  }
39
42
  cleanup() {
40
43
  if (this.interval) {
@@ -66,7 +69,7 @@ export class PollManager {
66
69
  this.consecutiveErrors++;
67
70
  this.options.onError?.(error);
68
71
  if (this.consecutiveErrors >= this.options.maxRetries) {
69
- this.stop();
72
+ this.halt(error);
70
73
  }
71
74
  }
72
75
  }