arcane-os 0.33.4 → 0.33.5

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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.5
4
+
5
+ - Deliver the first visible streamed chat chunk to speech exactly once, without
6
+ waiting for speech playback or changing response order.
7
+ - Keep a following transcript at its actual bottom through message and composer
8
+ layout changes while preserving a reader's deliberate scroll-away choice.
9
+ - Retry pre-response Fetch failures and HTTP 529 up to three times after a
10
+ three-second delay. Preserve existing HTTP 429 overload retries, cancellation,
11
+ complete request content, and terminal errors without replaying streams or tools.
12
+ - Add observational `onRetry` waiting/requesting notifications to portable TWiN
13
+ and shared AI request APIs, including queued built-in provider requests.
14
+ - Let modal task jobs report complete transient progress to their own row through
15
+ `task(reportProgress)`, preserving parallel work and settled results.
16
+
3
17
  ## 0.33.4
4
18
 
5
19
  - Treat a repeated reader `AbortError` during intentional HTTP tool-text stream
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.33.4` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.33.5` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -85,7 +85,7 @@ Create one browser application, install its pinned SDK, and start its source
85
85
  server:
86
86
 
87
87
  ```bash
88
- npx arcane-os@0.33.4 new hello-speech --path ./hello-speech --target browser
88
+ npx arcane-os@0.33.5 new hello-speech --path ./hello-speech --target browser
89
89
  cd hello-speech
90
90
  npm install
91
91
  ```
@@ -17,7 +17,8 @@ export async function fetchRequest({
17
17
  signal = null,
18
18
  id = Date.now(),
19
19
  onRequest = function observeTWiNRequest(){},
20
- onResponse = function observeTWiNResponse(){}
20
+ onResponse = function observeTWiNResponse(){},
21
+ onRetry = null
21
22
  } = {}){
22
23
  if(signal?.aborted){
23
24
  throw normalizeAIRequestAbort(signal.reason);
@@ -67,7 +68,8 @@ export async function fetchRequest({
67
68
  },
68
69
  body:JSON.stringify(request),
69
70
  ...(signal ? {signal} : {})
70
- }
71
+ },
72
+ {onRetry}
71
73
  );
72
74
  if(signal?.aborted){
73
75
  throw normalizeAIRequestAbort(signal.reason);
@@ -141,72 +143,128 @@ export function openAIResponseFormat(format){
141
143
  }
142
144
 
143
145
  /** Shared with browser streaming; consume no successful body at this boundary. */
144
- export async function fetchHTTPResponse(url, options){
146
+ export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
145
147
  const {signal} = options;
146
148
  const retryDelayMs = 3000;
147
- try{
148
- while(true){
149
- if(signal?.aborted){
149
+ const maxRecoveryRetries = 3;
150
+ let recoveryRetries = 0;
151
+ let attempt = 0;
152
+ try {
153
+ while (true) {
154
+ if (signal?.aborted) {
150
155
  throw normalizeAIRequestAbort(signal.reason);
151
156
  }
152
- const response = await fetch(url, options);
153
- if(signal?.aborted){
157
+ let response;
158
+ let error;
159
+ try {
160
+ response = await fetch(url, options);
161
+ } catch (fetchError) {
162
+ if (isAIRequestAbort(fetchError, signal)) {
163
+ throw normalizeAIRequestAbort(fetchError);
164
+ }
165
+ error = fetchError;
166
+ }
167
+ if (signal?.aborted) {
154
168
  throw normalizeAIRequestAbort(signal.reason);
155
169
  }
156
- if(response.ok){
170
+ if (response?.ok) {
157
171
  return response;
158
172
  }
159
- const contentType = response.headers.get('content-type') || '';
160
- const error = contentType.includes('application/json')
161
- ? await response.json()
162
- : await response.text();
163
- if(signal?.aborted){
164
- throw normalizeAIRequestAbort(signal.reason);
173
+ if (response) {
174
+ const contentType = response.headers.get('content-type') || '';
175
+ error = contentType.includes('application/json')
176
+ ? await response.json()
177
+ : await response.text();
178
+ if (signal?.aborted) {
179
+ throw normalizeAIRequestAbort(signal.reason);
180
+ }
165
181
  }
182
+ const status = response?.status ?? null;
166
183
  const message = is.string(error)
167
184
  ? error
168
185
  : error?.error?.message ?? error?.message;
169
- if(
170
- response.status !== 429
171
- || !is.string(message)
172
- || !message.toLowerCase().includes('overload')
173
- ){
174
- throw error;
186
+ const overload = status === 429
187
+ && is.string(message)
188
+ && message.toLowerCase().includes('overload');
189
+ if (!overload) {
190
+ if ((status !== null && status !== 529)
191
+ || recoveryRetries >= maxRecoveryRetries) {
192
+ throw error;
193
+ }
194
+ recoveryRetries += 1;
175
195
  }
196
+ attempt += 1;
176
197
  arcaneLogging.warn(
177
- `${message}\nRetrying in ${retryDelayMs / 1000} seconds`,
198
+ `${message ?? 'AI request transport failed.'}\nRetrying in ${retryDelayMs / 1000} seconds`,
178
199
  error
179
200
  );
180
- await new Promise(function waitForOverloadRetry(resolve, reject){
181
- function finishRetryDelay(){
182
- signal?.removeEventListener('abort', cancelRetryDelay);
183
- resolve();
184
- }
185
- function cancelRetryDelay(){
186
- clearTimeout(timer);
187
- signal.removeEventListener('abort', cancelRetryDelay);
188
- reject(normalizeAIRequestAbort(signal.reason));
189
- }
190
- const timer = setTimeout(finishRetryDelay, retryDelayMs);
191
- signal?.addEventListener('abort', cancelRetryDelay, {once:true});
192
- if(signal?.aborted){
193
- cancelRetryDelay();
201
+ observeRequestRetry(
202
+ onRetry,
203
+ {phase: 'waiting', attempt, delayMs: retryDelayMs, status, error}
204
+ );
205
+ await new Promise(
206
+ function waitForRequestRetry(resolve, reject) {
207
+ function finishRetryDelay() {
208
+ signal?.removeEventListener('abort', cancelRetryDelay);
209
+ resolve();
210
+ }
211
+ function cancelRetryDelay() {
212
+ clearTimeout(timer);
213
+ signal.removeEventListener('abort', cancelRetryDelay);
214
+ reject(normalizeAIRequestAbort(signal.reason));
215
+ }
216
+ const timer = setTimeout(finishRetryDelay, retryDelayMs);
217
+ signal?.addEventListener(
218
+ 'abort',
219
+ cancelRetryDelay,
220
+ {once: true}
221
+ );
222
+ if (signal?.aborted) {
223
+ cancelRetryDelay();
224
+ }
194
225
  }
195
- });
226
+ );
227
+ if (signal?.aborted) {
228
+ throw normalizeAIRequestAbort(signal.reason);
229
+ }
230
+ observeRequestRetry(
231
+ onRetry,
232
+ {phase: 'requesting', attempt, delayMs: retryDelayMs, status, error}
233
+ );
196
234
  }
197
- }catch(error){
198
- if(isAIRequestAbort(error, signal)){
235
+ } catch (error) {
236
+ if (isAIRequestAbort(error, signal)) {
199
237
  throw normalizeAIRequestAbort(error);
200
238
  }
201
239
  throw error;
202
240
  }
203
241
  }
204
242
 
243
+ function observeRequestRetry(onRetry, state) {
244
+ if (!is.function(onRetry)) {
245
+ return;
246
+ }
247
+ function reportRetryObserverFailure(error) {
248
+ arcaneLogging.error('Arcane AI retry observer failed.', error);
249
+ }
250
+ try {
251
+ Promise.resolve(
252
+ onRetry(state)
253
+ ).catch(reportRetryObserverFailure);
254
+ } catch (error) {
255
+ reportRetryObserverFailure(error);
256
+ }
257
+ }
258
+
205
259
  /** Return the entire parsed completion without selecting or rewriting choices. */
206
- export async function fetchJSONResponse(url, options){
260
+ export async function fetchJSONResponse(url, options, {onRetry = null} = {}) {
207
261
  const {signal} = options;
208
262
  try{
209
- const response = await fetchHTTPResponse(url, options);
263
+ const response = await fetchHTTPResponse(
264
+ url,
265
+ options,
266
+ {onRetry}
267
+ );
210
268
  if(signal?.aborted){
211
269
  throw normalizeAIRequestAbort(signal.reason);
212
270
  }
@@ -75,9 +75,15 @@ The key is
75
75
  transport authentication, not part of either callback's message payload. Keep
76
76
  credentials out of application logging as well.
77
77
 
78
- Only HTTP `429` with a message containing `overload` (case-insensitive) repeats automatically,
79
- after `3000` milliseconds. Another overload repeats the same complete request;
80
- other HTTP failures do not become an automatic retry loop. Pass a fresh
78
+ Before a successful response is consumed, a rejected Fetch or HTTP `529` can
79
+ retry up to three times, waiting `3000` milliseconds before each retry. These
80
+ two failure types share that three-retry budget. HTTP `429` with a message
81
+ containing `overload` (case-insensitive) retains its unlimited three-second
82
+ retry behavior and does not consume that budget. Each attempt reuses the exact
83
+ destination, Fetch options, serialized request, and signal. Other HTTP errors,
84
+ response decoding failures, and application callback failures do not retry.
85
+ The last complete failure is thrown unchanged when recovery is exhausted.
86
+ Pass a fresh
81
87
  `AbortController`'s `signal` and call `abort()` to cancel. Cancellation during
82
88
  the request, response-body read, retry wait, or callback settlement prevents
83
89
  successful result delivery and rejects with `ARCANE_AI_REQUEST_ABORTED`.
@@ -86,6 +92,17 @@ A missing key uses `AI_PROVIDER_NOT_CONFIGURED`, a missing explicit model
86
92
  throws `TypeError`, and an unsupported `structuredOutput` input uses
87
93
  `AI_STRUCTURED_OUTPUT_INVALID`.
88
94
 
95
+ Optional `onRetry({phase,attempt,delayMs,status,error})` observes each retry.
96
+ `phase:'waiting'` arrives before the abortable delay; `phase:'requesting'`
97
+ arrives immediately before the next Fetch. `attempt` is the one-based upcoming
98
+ retry number across both retry policies, `delayMs` is `3000`, `status` is the
99
+ HTTP status or `null` for a rejected Fetch, and `error` is the complete original
100
+ failure value. This observer runs synchronously without awaiting its returned
101
+ promise. Synchronous throws and rejected promises are reported through the
102
+ shared console logger and cannot change the request outcome or trigger another
103
+ retry. `onRequest` still runs once per logical request. Retry observations stay
104
+ outside the model payload and retained history.
105
+
89
106
  The SDK retains no request or response history between calls and uses no
90
107
  DBOPFS, chat entity, or memory extraction. Each call's `messages` are its
91
108
  complete caller-supplied context. The caller decides whether and how to keep
@@ -104,8 +121,8 @@ the existing implementation rather than maintain another retry or body reader.
104
121
 
105
122
  | Export | Contract |
106
123
  | --- | --- |
107
- | `fetchHTTPResponse(url,options)` | Uses the caller's Fetch options, overload retry and cancellation; returns a successful `Response` with its body unconsumed. |
108
- | `fetchJSONResponse(url,options)` | Uses that HTTP owner, requires `application/json`, and returns the complete parsed body without selecting choices. |
124
+ | `fetchHTTPResponse(url,options,{onRetry=null}={})` | Uses the caller's exact Fetch options and shared retry/cancellation behavior; returns a successful `Response` with its body unconsumed. The separate optional control observes retries without entering Fetch options. |
125
+ | `fetchJSONResponse(url,options,{onRetry=null}={})` | Uses that HTTP owner and retry observer, requires `application/json`, and returns the complete parsed body without selecting choices. |
109
126
  | `structuredOutputFormat(value=false)` | Maps false/null/undefined to null, true/`'json'` to `'json'`, and preserves a supplied plain JSON Schema object. Other inputs use `AI_STRUCTURED_OUTPUT_INVALID`. |
110
127
  | `openAIResponseFormat(format)` | Maps the normalized value to `json_object`, strict `json_schema` named `structured_response`, or null. |
111
128
  | `isAIRequestAbort(error,signal)` | Recognizes an aborted signal, `AbortError`, or the existing Arcane AI/request cancellation codes. |
@@ -113,8 +130,8 @@ the existing implementation rather than maintain another retry or body reader.
113
130
 
114
131
  These helpers start no work on import. HTTP helpers require explicit URL and
115
132
  options; they do not add a key, model, browser state, or retained conversation.
116
- Overload warnings use the shared console logger and preserve the complete
117
- provider error.
133
+ Retry warnings and observer failures use the shared console logger and preserve
134
+ the complete original error.
118
135
 
119
136
  ## Existing browser AI interface
120
137
 
@@ -78,7 +78,7 @@ version; WebKitGTK availability must not be generalized to macOS.
78
78
  | Read host identity, capabilities, storage, preferences, appearance, or platform state | `globalThis.Arcane` | **Cross-host** where the method is implemented and admitted | Promise behavior and `Arcane.Error` are normalized. Result fields are normalized unless the method explicitly documents a platform-dependent snapshot. |
79
79
  | Use local AI without coupling app code to Ollama HTTP | `Arcane.localAI`, `Arcane.ai`, or `/arcane/modules/Ollama.js` | Primarily **Native**; Android exposes a narrower admitted inference projection | Admission, errors, and managed-operation events are normalized. Direct Ollama response envelopes remain **Provider-native**. |
80
80
  | Use TWiN Cloud from the renderer profile | `/arcane/modules/AI.js` | **Cloud** from an allowed browser/native renderer | High-level chat behavior is normalized by the module. The TWiN access key authenticates remote LLM chat; raw provider diagnostics remain provider-specific. No automatic cloud fallback is inferred from local failure. |
81
- | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Shared structured-output mapping, overload-only HTTP 429 retry after 3000 ms, and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
81
+ | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Rejected Fetch and HTTP 529 share three retries after 3000 ms; HTTP 429 overload retains unlimited three-second retries. Optional `onRetry` reports waiting/requesting phases without blocking recovery. Shared structured-output mapping and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
82
82
  | Use speech through one application helper | `/arcane/modules/AI.js` and `Arcane.speech` | **Browser** or **Native** | The helper keeps audio on device: Whisper owns STT and Kokoro owns TTS. It automatically cleans only the outbound speech-input copy and normalizes application-facing audio/text behavior while browser and native request/response plumbing differs below that boundary. |
83
83
  | Inspect or manage raw Ollama models | `Arcane.ollama` or `/arcane/modules/Ollama.js` | **Native** desktop Core for management; narrower Android inference only | Wrapper method names, errors, streaming correlation, and admission are Arcane-controlled. Direct Ollama success envelopes are intentionally provider-native. |
84
84
  | Use native terminal, installation, user, provisioning, or machine controls | matching `Arcane.*` namespace | **Native** and app/capability restricted | Calls and errors use the common bridge contract. Platform results can be host-specific and are marked in the method guide. |
@@ -339,8 +339,12 @@ suppressed.
339
339
  module or an existing compatible session, restores the UI transcript when
340
340
  available, and uses provider-safe history as its restoration fallback. The
341
341
  transcript is a masked vertical scroll viewport; status and composer remain
342
- outside it. Initial restoration and every user, assistant, tool, streaming,
343
- progress, or failure mutation scrolls that viewport to its true bottom. Each
342
+ outside it. Initial restoration follows the true bottom. User, assistant, tool,
343
+ streaming, progress, and failure updates continue following it, including later
344
+ message-card, status, composer, or viewport resizing, until the reader scrolls
345
+ toward earlier content. Scrolling down to the bottom resumes following. Layout
346
+ clamping, hidden views, and BFCache restoration preserve that choice;
347
+ destruction releases scroll observation. Each
344
348
  restored or new message uses a separate semantic `<time>` element at the card's
345
349
  lower-right with an ISO `datetime`, full local title, and local 24-hour `HH:MM`
346
350
  text.
@@ -475,10 +479,13 @@ terminal result of the segments submitted by one invocation; Chat continues
475
479
  to feed chunks without waiting for playback. Runtime mute, cancellation,
476
480
  permission waiting, and stale generations remain non-errors.
477
481
 
478
- Each visible model chunk is forwarded to `AI.streamTTS(text)` in arrival
479
- order. The AI owner automatically removes repeated formatting marks from an
480
- outbound speech-input copy before the
481
- configured segmentation, admits completed segments to bounded synthesis
482
+ Each visible model chunk, including the first chunk that creates its assistant
483
+ card, renders once in arrival order. When speech is ready and unmuted, each
484
+ chunk also reaches `AI.streamTTS(text)` once. Card creation is synchronous, so
485
+ consecutive chunks retain their original order without waiting for speech
486
+ preparation or playback. Thinking content remains outside speech forwarding.
487
+ The AI owner automatically removes repeated formatting marks from an outbound
488
+ speech-input copy before the configured segmentation, admits completed segments to bounded synthesis
482
489
  immediately, and schedules the contiguous ready audio prefix on the audio
483
490
  clock. Chat retains the original Markdown for display and storage and owns no
484
491
  second speech queue. No app option selects or disables this cleanup. Single
@@ -988,6 +995,16 @@ to `true`; `destroy()` releases the component. Product-specific conditions for
988
995
  closing a modal belong to the application. Content, actions and task results
989
996
  are preserved, and `runTasks()` continues to execute independent jobs concurrently.
990
997
 
998
+ `runTasks(title, jobs)` calls each `job.task(reportProgress)` concurrently.
999
+ The optional callback accepts complete plain-text status for that pending job
1000
+ only and returns `true` when displayed. For example, a request can report its
1001
+ retry delay without replacing another job's progress. Reports after that job
1002
+ settles or the modal is destroyed return `false` and change no UI. A display
1003
+ failure is logged without rejecting the task. Existing zero-argument task
1004
+ functions remain supported, results retain `Promise.allSettled` order, and
1005
+ destruction does not cancel the application-owned work. Progress is transient
1006
+ display content, never a task result or conversation record.
1007
+
991
1008
  Events: `modal-ready`, `modal-opened`, `modal-closed`, `modal-action`.
992
1009
 
993
1010
  Slots: `header`, `body`, `footer`.
@@ -166,7 +166,8 @@ provider implementations; neither form is a retired compatibility API.
166
166
  Built-in cloud chat decodes an HTTP error body once as JSON or text and rejects
167
167
  with that complete value unchanged. It does not reconstruct an Error, replace
168
168
  the message, or add `providerMessage`, `status`, or an SDK failure code to the
169
- provider body. Network and decoding errors also pass through; cancellation
169
+ provider body. Network errors pass through after the retry policy below;
170
+ decoding errors pass through immediately. Cancellation
170
171
  retains the existing `ARCANE_AI_REQUEST_ABORTED` contract.
171
172
 
172
173
  When the HTTP status is `429` and the existing `error.message`, `message`, or
@@ -178,8 +179,26 @@ the same destination, headers, complete serialized body, and cancellation
178
179
  signal; `onRequest` runs once for the logical request. Cancellation stops the
179
180
  delay and prevents another attempt. Retrying happens before a successful
180
181
  response is consumed, so partial streams and tool callbacks are never replayed.
181
- Native Ollama and externally supplied provider adapters retain their own
182
- transport behavior.
182
+ Rejected Fetch calls and HTTP `529` share a separate budget of three retries,
183
+ each after the same `3000` millisecond delay. HTTP `429` overload retries do not
184
+ consume that budget. The final complete network error or provider body passes
185
+ through unchanged when recovery is exhausted. Aborts, other HTTP errors,
186
+ successful-response body reads, stream decoding, and application callbacks
187
+ never start another attempt.
188
+
189
+ `fetchRequest()` and `streamRequest()` accept
190
+ `onRetry({phase,attempt,delayMs,status,error})`. The observer receives
191
+ `phase:'waiting'` before the abortable delay and `phase:'requesting'` immediately
192
+ before the next Fetch. `attempt` is the one-based upcoming retry number across
193
+ both retry policies; `delayMs` is `3000`; `status` is the HTTP status or `null`
194
+ for rejected Fetch; `error` is the complete original failure value. The SDK
195
+ invokes this observational callback synchronously and observes a returned
196
+ promise without awaiting it. Callback throws and rejections are logged through
197
+ the shared console owner without changing request success or causing retries.
198
+ The callback remains outside model payloads and is forwarded through built-in
199
+ provider controls, including queued requests, with the request owner's existing
200
+ cancellation lifetime. Native Ollama and externally supplied provider adapters
201
+ retain their own transport behavior.
183
202
 
184
203
  Initialization uses the canonical realm user's actual readiness state. If
185
204
  `window.user?.ready` is already true, AI initializes immediately. Otherwise one
@@ -472,8 +491,10 @@ cleanup errors remain visible in developer-console diagnostics.
472
491
 
473
492
  Request observers receive
474
493
  `onRequest(request,id,metadata)` and any transport metadata supplied by the
475
- selected route is forwarded unchanged. Every async native, HTTP, provider, and
476
- built-in callback is observed before the next callback or terminal settlement.
494
+ selected route is forwarded unchanged. Async request, response, content, and
495
+ tool callbacks are observed before the next callback or terminal settlement.
496
+ The observational `onRetry` callback has the separate nonblocking behavior
497
+ described above.
477
498
 
478
499
  Native Ollama responses are adapted before the shared structural validator:
479
500
  provider-native calls may omit `id` and `type` or provide object arguments, so
@@ -914,6 +935,12 @@ caller's payload and request records remain unchanged. The optional
914
935
  boundary has already cleaned the copy. Applications omit that argument. LLM
915
936
  and STT payloads are unaffected.
916
937
 
938
+ SDK-owned LLM delegation also carries `onRetry` in the separate `preparation`
939
+ control record, alongside `observeToolText` for streams. The runtime preserves
940
+ these controls across its request queue and passes them to the selected
941
+ provider without inserting callbacks in the provider payload. Applications use
942
+ the `AI.fetchRequest()` and `AI.streamRequest()` options described above.
943
+
917
944
  `register()` returns the provider's single unregister closure; caller-
918
945
  registered providers remain caller-owned. The high-level
919
946
  `AI.configureBrowserSpeech()` boundary is different: AI constructs, registers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.33.4",
3
+ "version": "0.33.5",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -86,7 +86,7 @@
86
86
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
87
87
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
88
88
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
89
- "test:functional": "node ./bin/arcane-test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs",
89
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs",
90
90
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
91
91
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
92
92
  "check": "node tools/check-source.mjs && npm test",
@@ -1083,8 +1083,89 @@
1083
1083
  &&is.function(session.send);
1084
1084
  }
1085
1085
 
1086
- function scrollTranscriptToBottom(){
1087
- chatOutput.scrollTop=chatOutput.scrollHeight;
1086
+ let transcriptFollowing = true;
1087
+ let transcriptScrollTop = chatOutput.scrollTop;
1088
+ let transcriptWasHidden = chatOutput.clientHeight === 0;
1089
+ const transcriptResizeObserver = is.function(globalThis.ResizeObserver)
1090
+ ? new ResizeObserver(scrollTranscriptToBottom)
1091
+ : null;
1092
+ const transcriptMutationObserver = is.function(globalThis.MutationObserver)
1093
+ ? new MutationObserver(handleTranscriptChildren)
1094
+ : null;
1095
+
1096
+ chatOutput.addEventListener(
1097
+ 'scroll',
1098
+ trackTranscriptScrollPosition,
1099
+ {
1100
+ passive: true,
1101
+ signal: aiRuntimeStateAbortController.signal
1102
+ }
1103
+ );
1104
+ transcriptResizeObserver?.observe(
1105
+ chatOutput,
1106
+ {box: 'border-box'}
1107
+ );
1108
+ for(const item of chatOutput.children) {
1109
+ if(item.localName === 'li') {
1110
+ transcriptResizeObserver?.observe(
1111
+ item,
1112
+ {box: 'border-box'}
1113
+ );
1114
+ }
1115
+ }
1116
+ transcriptMutationObserver?.observe(
1117
+ chatOutput,
1118
+ {childList: true}
1119
+ );
1120
+
1121
+ function trackTranscriptScrollPosition() {
1122
+ if(destroyed) return false;
1123
+ if(chatOutput.clientHeight === 0) {
1124
+ transcriptWasHidden = true;
1125
+ return false;
1126
+ }
1127
+ const scrollTop = chatOutput.scrollTop;
1128
+ if(transcriptWasHidden) {
1129
+ transcriptWasHidden = false;
1130
+ transcriptScrollTop = scrollTop;
1131
+ return true;
1132
+ }
1133
+ const atBottom = chatOutput.scrollHeight - chatOutput.clientHeight - scrollTop <= 1;
1134
+ // A layout clamp reaching the bottom does not resume a reader's following.
1135
+ if(scrollTop < transcriptScrollTop && !atBottom) {
1136
+ transcriptFollowing = false;
1137
+ } else if(scrollTop > transcriptScrollTop && atBottom) {
1138
+ transcriptFollowing = true;
1139
+ }
1140
+ transcriptScrollTop = scrollTop;
1141
+ return true;
1142
+ }
1143
+
1144
+ function handleTranscriptChildren(records) {
1145
+ if(destroyed) return;
1146
+ for(const record of records) {
1147
+ for(const item of record.removedNodes) {
1148
+ if(item.localName === 'li' && item.parentElement !== chatOutput) {
1149
+ transcriptResizeObserver?.unobserve(item);
1150
+ }
1151
+ }
1152
+ for(const item of record.addedNodes) {
1153
+ if(item.localName === 'li' && item.parentElement === chatOutput) {
1154
+ transcriptResizeObserver?.observe(
1155
+ item,
1156
+ {box: 'border-box'}
1157
+ );
1158
+ }
1159
+ }
1160
+ }
1161
+ scrollTranscriptToBottom();
1162
+ }
1163
+
1164
+ function scrollTranscriptToBottom() {
1165
+ // Read movement before writing: a reader's scroll event may still be queued.
1166
+ if(!trackTranscriptScrollPosition() || !transcriptFollowing) return false;
1167
+ chatOutput.scrollTop = chatOutput.scrollHeight;
1168
+ transcriptScrollTop = chatOutput.scrollTop;
1088
1169
  return true;
1089
1170
  }
1090
1171
 
@@ -1513,6 +1594,8 @@
1513
1594
  actionable?[...restoredPendingToolCalls.values()]:[]
1514
1595
  );
1515
1596
  chatOutput.replaceChildren(fragment);
1597
+ transcriptFollowing = true;
1598
+ transcriptScrollTop = chatOutput.scrollTop;
1516
1599
  scrollTranscriptToBottom();
1517
1600
  return rendered;
1518
1601
  }
@@ -2014,6 +2097,10 @@
2014
2097
  ? selectedRole.localOnly === false
2015
2098
  || (selectedRole.state === 'ready' && selectedRole.loaded)
2016
2099
  : availability.llm;
2100
+ if(!conversationVisible && !host.conversationComplete) {
2101
+ trackTranscriptScrollPosition();
2102
+ transcriptWasHidden = true;
2103
+ }
2017
2104
  chatArea.dataset.modelStartRequired = String(!conversationVisible);
2018
2105
  send.disabled=host.conversationComplete
2019
2106
  ||!availability.llm
@@ -2835,21 +2922,15 @@
2835
2922
  return false;
2836
2923
  }
2837
2924
 
2838
- let message=[...chatOutput.children].find(
2839
- candidate=>candidate.id===`message-${id}`
2840
- )||null;
2841
-
2842
- if(!message){
2843
- await receivedMessage(host.aiName,isThinking?'':text,id);
2844
- if(!isThinking){
2845
- return false;
2846
- }
2847
- message=[...chatOutput.children].find(
2848
- candidate=>candidate.id===`message-${id}`
2849
- )||null;
2850
- if(!message){
2851
- return false;
2925
+ let message = [...chatOutput.children].find(
2926
+ function findStreamedMessage(candidate) {
2927
+ return candidate.id === `message-${id}`;
2852
2928
  }
2929
+ ) || null;
2930
+
2931
+ if(!message) {
2932
+ // The first chunk uses the same display and speech path as later chunks.
2933
+ message = receivedMessage(host.aiName, '', id);
2853
2934
  }
2854
2935
 
2855
2936
  let target=message.querySelector('.markdown');
@@ -3060,8 +3141,12 @@
3060
3141
  }
3061
3142
 
3062
3143
  // Reset height to shrink to content size
3144
+ trackTranscriptScrollPosition();
3063
3145
  textArea.style.height = 'auto';
3064
3146
  textArea.style.height = `${textArea.scrollHeight}px`;
3147
+ // The intermediate auto height can clamp scrollTop before final layout.
3148
+ transcriptScrollTop = chatOutput.scrollTop;
3149
+ scrollTranscriptToBottom();
3065
3150
  }
3066
3151
 
3067
3152
  function renderSessionMessageFailure(messageId,error){
@@ -3164,7 +3249,7 @@
3164
3249
  applyAIAvailability(host.aiAvailability);
3165
3250
  setSessionStatus('sending','AI is responding…');
3166
3251
  try{
3167
- await receivedMessage(host.aiName,'',messageId);
3252
+ receivedMessage(host.aiName, '', messageId);
3168
3253
  await streamMessage('',messageId,true);
3169
3254
  setMessageProgress(
3170
3255
  messageId,
@@ -3775,7 +3860,7 @@
3775
3860
  }
3776
3861
  }
3777
3862
 
3778
- async function receivedMessage(name = 'Assistant', text = '', id=''){
3863
+ function receivedMessage(name = 'Assistant', text = '', id = '') {
3779
3864
  const message=appendTranscriptMessage('assistant',text,name);
3780
3865
  if(id){
3781
3866
  message.id=`message-${id}`;
@@ -3854,6 +3939,8 @@
3854
3939
  sessionBindingGeneration+=1;
3855
3940
  setSessionStatus('destroyed','Chat closed.');
3856
3941
  destroyed=true;
3942
+ transcriptResizeObserver?.disconnect();
3943
+ transcriptMutationObserver?.disconnect();
3857
3944
  const destroyReason=createChatSubmissionAbort(
3858
3945
  chatReasons.componentDestroyed,
3859
3946
  'The chat component was destroyed before message submission settled.'
@@ -421,16 +421,38 @@
421
421
  await open();
422
422
 
423
423
  activeRun=Promise.allSettled(tasks.map(async function runProgressTask(job,index){
424
+ let settled = false;
425
+ function displayTaskProgress(message, state) {
426
+ if (disposed) {
427
+ return false;
428
+ }
429
+ try {
430
+ if (state) {
431
+ rows[index].row.dataset.status = state;
432
+ }
433
+ rows[index].status.innerText = message;
434
+ return true;
435
+ } catch (error) {
436
+ arcaneLogging.error(`Unable to display progress for ${job.name}:`, error);
437
+ return false;
438
+ }
439
+ }
440
+ function reportProgress(message) {
441
+ if (settled || !is.string(message)) {
442
+ return false;
443
+ }
444
+ return displayTaskProgress(message);
445
+ }
424
446
  try{
425
- const result=await job.task();
426
- rows[index].row.dataset.status='complete';
427
- rows[index].status.innerText='Complete';
447
+ const result=await job.task(reportProgress);
448
+ displayTaskProgress('Complete', 'complete');
428
449
  return result;
429
450
  }catch(error){
430
- rows[index].row.dataset.status='failed';
431
- rows[index].status.innerText='Unable to complete';
451
+ displayTaskProgress('Unable to complete', 'failed');
432
452
  arcaneLogging.error(`Unable to complete ${job.name}:`,error);
433
453
  throw error;
454
+ } finally {
455
+ settled = true;
434
456
  }
435
457
  }));
436
458
 
@@ -439,6 +461,9 @@
439
461
  return result.status==='rejected';
440
462
  });
441
463
  running=false;
464
+ if (disposed) {
465
+ return results;
466
+ }
442
467
  updateCloseButton();
443
468
 
444
469
  if(failed){
@@ -1484,7 +1484,8 @@ class AI {
1484
1484
  return Promise.resolve(
1485
1485
  runtime.#requestBuiltInLLMChat(
1486
1486
  context.payload,
1487
- context.signal
1487
+ context.signal,
1488
+ controls.onRetry
1488
1489
  )
1489
1490
  ).finally(releaseBuiltInLLMRequest);
1490
1491
  }
@@ -1494,7 +1495,8 @@ class AI {
1494
1495
  return runtime.#requestBuiltInLLMStream(
1495
1496
  context.payload,
1496
1497
  bridge,
1497
- controls.observeToolText
1498
+ controls.observeToolText,
1499
+ controls.onRetry
1498
1500
  );
1499
1501
  },
1500
1502
  context.signal
@@ -4154,7 +4156,7 @@ class AI {
4154
4156
  return is.string(content)?content:completion;
4155
4157
  }
4156
4158
 
4157
- #requestBuiltInLLMChat(payload={},signal=null){
4159
+ #requestBuiltInLLMChat(payload = {}, signal = null, onRetry = null) {
4158
4160
  const parallelToolCalls=payload.parallelToolCalls!==undefined
4159
4161
  ?payload.parallelToolCalls
4160
4162
  :payload.parallel_tool_calls;
@@ -4168,11 +4170,12 @@ class AI {
4168
4170
  payload.id??Date.now(),
4169
4171
  function ignoreBuiltInLLMProviderRequest(){},
4170
4172
  signal,
4171
- payload.reasoningEffort
4173
+ payload.reasoningEffort,
4174
+ onRetry
4172
4175
  );
4173
4176
  }
4174
4177
 
4175
- #requestBuiltInLLMStream(payload={},bridge,observeToolText=null){
4178
+ #requestBuiltInLLMStream(payload = {}, bridge, observeToolText = null, onRetry = null) {
4176
4179
  const parallelToolCalls=payload.parallelToolCalls!==undefined
4177
4180
  ?payload.parallelToolCalls
4178
4181
  :payload.parallel_tool_calls;
@@ -4198,7 +4201,8 @@ class AI {
4198
4201
  emitBuiltInLLMStreamData,
4199
4202
  function ignoreBuiltInLLMStreamResult(){},
4200
4203
  payload.reasoningEffort,
4201
- observeToolText
4204
+ observeToolText,
4205
+ onRetry
4202
4206
  );
4203
4207
  }
4204
4208
 
@@ -4357,6 +4361,7 @@ class AI {
4357
4361
  toolChoice='auto',
4358
4362
  onToolCall=function ignoreEarlyFunction(){},
4359
4363
  onRequest=function ignoreStreamRequest(){},
4364
+ onRetry = null,
4360
4365
  parallelToolCalls,
4361
4366
  id=Date.now(),
4362
4367
  seeThinking=false,
@@ -4438,7 +4443,7 @@ class AI {
4438
4443
  localOnly,
4439
4444
  signal
4440
4445
  },
4441
- {observeToolText}
4446
+ {observeToolText, onRetry}
4442
4447
  );
4443
4448
  for await(const chunk of handle){
4444
4449
  if(signal?.aborted){
@@ -4523,7 +4528,8 @@ class AI {
4523
4528
  onDataChunk,
4524
4529
  onDataResult,
4525
4530
  normalizedReasoningEffort,
4526
- observeToolText
4531
+ observeToolText,
4532
+ onRetry
4527
4533
  );
4528
4534
  const structuralToolCalls=normalizeAICompletionToolCalls(
4529
4535
  completion
@@ -4631,7 +4637,8 @@ class AI {
4631
4637
  dataChunkHandler=function ignoreBuiltInStreamDataChunk(){},
4632
4638
  dataResultHandler=function ignoreBuiltInStreamDataResult(){},
4633
4639
  reasoningEffort,
4634
- observeToolText=null
4640
+ observeToolText=null,
4641
+ onRetry = null
4635
4642
  ){
4636
4643
  let speechTurnCompleted=false;
4637
4644
 
@@ -4863,7 +4870,8 @@ class AI {
4863
4870
  headers:this.#serviceHeaders[this.llmService],
4864
4871
  body,
4865
4872
  ...(signal?{signal}:{})
4866
- }
4873
+ },
4874
+ {onRetry}
4867
4875
  );
4868
4876
 
4869
4877
  let sseBuffer='';
@@ -5357,6 +5365,7 @@ class AI {
5357
5365
  signal=null,
5358
5366
  onRequest=function ignoreFetchRequest(){},
5359
5367
  onResponse=function ignoreFetchResponse(){},
5368
+ onRetry = null,
5360
5369
  maxOutputTokens,
5361
5370
  maxTokens,
5362
5371
  temperature,
@@ -5420,7 +5429,8 @@ class AI {
5420
5429
  payload:request,
5421
5430
  localOnly,
5422
5431
  signal
5423
- }
5432
+ },
5433
+ {onRetry}
5424
5434
  );
5425
5435
  if(signal?.aborted){
5426
5436
  throw normalizeAIRequestAbort();
@@ -5443,7 +5453,8 @@ class AI {
5443
5453
  id,
5444
5454
  onRequest,
5445
5455
  signal,
5446
- normalizedReasoningEffort
5456
+ normalizedReasoningEffort,
5457
+ onRetry
5447
5458
  );
5448
5459
  }
5449
5460
 
@@ -5496,7 +5507,8 @@ class AI {
5496
5507
  id=Date.now(),
5497
5508
  requestHandler=function ignoreFetchRequest(){},
5498
5509
  signal=null,
5499
- reasoningEffort
5510
+ reasoningEffort,
5511
+ onRetry = null
5500
5512
  ){
5501
5513
  validateAIStructuralRequest(messages,tools,parallel_tool_calls);
5502
5514
  this.#assertServiceConfigured(this.llmService);
@@ -5613,7 +5625,8 @@ class AI {
5613
5625
  headers:this.#serviceHeaders[this.llmService],
5614
5626
  body,
5615
5627
  ...(signal?{signal}:{})
5616
- }
5628
+ },
5629
+ {onRetry}
5617
5630
  );
5618
5631
 
5619
5632
  if(signal?.aborted){
@@ -3347,14 +3347,14 @@ export class AIProviderRuntime {
3347
3347
  return this.#disposeAllPromise;
3348
3348
  }
3349
3349
 
3350
- request(role, options = {}, {speechInputPrepared = false, observeToolText = null} = {}) {
3350
+ request(role, options = {}, {speechInputPrepared = false, observeToolText = null, onRetry = null} = {}) {
3351
3351
  if (SPEECH_ROLES.includes(role)) {
3352
3352
  arcaneLogging.debug('[Arcane speech runtime] request', role, options);
3353
3353
  }
3354
- return this.#requestRole(role, options, false, speechInputPrepared, observeToolText);
3354
+ return this.#requestRole(role, options, false, speechInputPrepared, observeToolText, onRetry);
3355
3355
  }
3356
3356
 
3357
- #requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null) {
3357
+ #requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null, onRetry = null) {
3358
3358
  this.#assertOpen();
3359
3359
  this.#assertNotConfiguring();
3360
3360
  assertRole(role);
@@ -3486,7 +3486,7 @@ export class AIProviderRuntime {
3486
3486
  }
3487
3487
  if ((!queued && slot.requestQueue.length)
3488
3488
  || slot.activeRequests.size >= maxConcurrentRequests) {
3489
- return this.#enqueueRoleRequest(slot, options, observeToolText);
3489
+ return this.#enqueueRoleRequest(slot, options, observeToolText, onRetry);
3490
3490
  }
3491
3491
  if (!slot.ready
3492
3492
  || providerStatus.state !== 'ready'
@@ -3808,7 +3808,7 @@ export class AIProviderRuntime {
3808
3808
  payload: options.payload,
3809
3809
  signal: controller.signal
3810
3810
  },
3811
- {observeToolText: observeCurrentToolText}
3811
+ {observeToolText: observeCurrentToolText, onRetry}
3812
3812
  );
3813
3813
  }
3814
3814
  );
@@ -4039,7 +4039,12 @@ export class AIProviderRuntime {
4039
4039
  }
4040
4040
  const result = role === 'tts'
4041
4041
  ? await provider.request(providerRequest, {speechInputPrepared: true})
4042
- : await provider.request(providerRequest);
4042
+ : role === 'llm'
4043
+ ? await provider.request(
4044
+ providerRequest,
4045
+ {onRetry}
4046
+ )
4047
+ : await provider.request(providerRequest);
4043
4048
  if (SPEECH_ROLES.includes(role)) {
4044
4049
  arcaneLogging.debug('[Arcane speech runtime] provider.request.result', {role, generation, requestSequence, operationId}, result);
4045
4050
  }
@@ -4222,7 +4227,7 @@ export class AIProviderRuntime {
4222
4227
  );
4223
4228
  }
4224
4229
 
4225
- #enqueueRoleRequest(slot, options, observeToolText = null) {
4230
+ #enqueueRoleRequest(slot, options, observeToolText = null, onRetry = null) {
4226
4231
  if (SPEECH_ROLES.includes(slot.role)) {
4227
4232
  arcaneLogging.debug('[Arcane speech runtime] queue.enqueue', {
4228
4233
  role: slot.role,
@@ -4237,6 +4242,7 @@ export class AIProviderRuntime {
4237
4242
  const entry = {
4238
4243
  options,
4239
4244
  observeToolText,
4245
+ onRetry,
4240
4246
  resolve,
4241
4247
  reject,
4242
4248
  detachSignal: null
@@ -4309,7 +4315,7 @@ export class AIProviderRuntime {
4309
4315
  let operation;
4310
4316
  try {
4311
4317
  operation = runtime.#requestRole(
4312
- slot.role, entry.options, true, false, entry.observeToolText
4318
+ slot.role, entry.options, true, false, entry.observeToolText, entry.onRetry
4313
4319
  );
4314
4320
  } catch (error) {
4315
4321
  if (SPEECH_ROLES.includes(slot.role)) {