@alia.onl/sdk 15.7.0 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +255 -12
  2. package/README.md +241 -155
  3. package/package.json +17 -5
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/csp.ts +50 -0
  43. package/src/hooks/useAliaActivity.ts +3 -3
  44. package/src/hooks/useAtBottom.ts +51 -0
  45. package/src/hooks/useAttachMenu.ts +94 -0
  46. package/src/hooks/useChatComposer.ts +299 -0
  47. package/src/hooks/useChatVoice.ts +129 -0
  48. package/src/hooks/useDictation.ts +2 -3
  49. package/src/hooks/useDocumentPicker.ts +3 -29
  50. package/src/hooks/useFailedTurnToast.ts +43 -0
  51. package/src/hooks/useMessageVote.ts +50 -0
  52. package/src/hooks/useOpenThought.ts +79 -0
  53. package/src/hooks/usePendingQuestions.ts +2 -2
  54. package/src/hooks/useReadAloud.ts +92 -0
  55. package/src/hooks/useShareHandler.ts +24 -0
  56. package/src/hooks/useSoundEffects.ts +1 -1
  57. package/src/hooks/useTTS.ts +2 -3
  58. package/src/hooks/useThoughtScope.ts +59 -0
  59. package/src/hooks/useThreadRows.ts +145 -0
  60. package/src/hooks/useThreadView.ts +68 -0
  61. package/src/hooks/useTimelineWindow.ts +256 -0
  62. package/src/hooks/useVoiceLauncher.ts +180 -0
  63. package/src/hooks/useVoiceRoom.ts +2 -6
  64. package/src/index.ts +43 -29
  65. package/src/lib/activity-store.ts +1 -1
  66. package/src/lib/add-menu.ts +7 -0
  67. package/src/lib/call-thread.ts +33 -0
  68. package/src/lib/character-runtime.ts +6 -4
  69. package/src/lib/citations.ts +79 -2
  70. package/src/lib/clipboard.ts +23 -0
  71. package/src/lib/content-utils.ts +10 -0
  72. package/src/lib/copy-en.ts +284 -0
  73. package/src/lib/copy.ts +32 -0
  74. package/src/lib/document-picker.ts +31 -0
  75. package/src/lib/flights.ts +22 -13
  76. package/src/lib/focus-return.ts +66 -0
  77. package/src/lib/keyboard.native.tsx +19 -0
  78. package/src/lib/keyboard.tsx +9 -0
  79. package/src/lib/labels.tsx +69 -17
  80. package/src/lib/origins.ts +15 -0
  81. package/src/lib/participants.ts +86 -0
  82. package/src/lib/power-level-options.ts +66 -0
  83. package/src/lib/questions.ts +2 -2
  84. package/src/lib/task-labels.ts +4 -4
  85. package/src/lib/tool-call-status.ts +1 -1
  86. package/src/lib/tool-icons.ts +36 -0
  87. package/src/lib/tool-renderers.tsx +48 -0
  88. package/src/lib/turn-state.ts +2 -2
  89. package/src/lib/voice-audio-engine.ts +14 -158
  90. package/src/lib/voice-call-request.ts +49 -0
  91. package/src/lib/voice-call.ts +76 -34
  92. package/src/lib/voice-controls-labels.ts +31 -0
  93. package/src/lib/voice-protocol.ts +32 -14
  94. package/src/lib/voice-worklets.ts +19 -0
  95. package/src/lib/work-log.ts +6 -6
  96. package/src/runtime/agents.ts +151 -0
  97. package/src/runtime/api.ts +123 -0
  98. package/src/runtime/attachment-utils.ts +96 -0
  99. package/src/runtime/composer-draft-store.ts +270 -0
  100. package/src/runtime/config.tsx +186 -0
  101. package/src/runtime/conversation-store.ts +49 -0
  102. package/src/runtime/conversations.ts +557 -0
  103. package/src/runtime/error-utils.ts +104 -0
  104. package/src/runtime/host-tools.ts +140 -0
  105. package/src/runtime/index.ts +113 -0
  106. package/src/runtime/message-history.ts +124 -0
  107. package/src/runtime/model.ts +39 -0
  108. package/src/runtime/oxy-files.ts +37 -0
  109. package/src/runtime/power-levels.ts +66 -0
  110. package/src/runtime/query-keys.ts +34 -0
  111. package/src/runtime/random-uuid.ts +25 -0
  112. package/src/runtime/routes.ts +51 -0
  113. package/src/runtime/sharing.ts +165 -0
  114. package/src/runtime/sse-frame-reader.ts +88 -0
  115. package/src/runtime/stream-events.ts +146 -0
  116. package/src/runtime/stream-outcome.ts +21 -0
  117. package/src/runtime/thought-store.ts +103 -0
  118. package/src/runtime/thread/agent-activity.ts +261 -0
  119. package/src/runtime/thread/automation-labels.ts +79 -0
  120. package/src/runtime/thread/conversation-export.ts +135 -0
  121. package/src/runtime/thread/message-days.ts +120 -0
  122. package/src/runtime/thread/message-processor.ts +93 -0
  123. package/src/runtime/thread/task-utils.ts +11 -0
  124. package/src/runtime/thread/thought-utils.ts +752 -0
  125. package/src/runtime/thread/thread-history.ts +139 -0
  126. package/src/runtime/thread/timeline.ts +202 -0
  127. package/src/runtime/thread/title-tags.ts +8 -0
  128. package/src/runtime/thread/tool-cards.ts +383 -0
  129. package/src/runtime/turn-failure.ts +108 -0
  130. package/src/runtime/types.ts +148 -0
  131. package/src/runtime/usage-limit-error.ts +44 -0
  132. package/src/runtime/use-agent-activity.ts +80 -0
  133. package/src/runtime/use-conversation-stream.ts +1427 -0
  134. package/src/runtime/use-conversation-thread.ts +556 -0
  135. package/src/runtime/use-start-conversation.ts +107 -0
  136. package/src/runtime/use-thread-history.ts +64 -0
  137. package/src/runtime/use-thread-search.ts +95 -0
  138. package/src/runtime/use-turn-edit.ts +99 -0
  139. package/src/tools.ts +2 -2
  140. package/src/types.ts +18 -47
  141. package/worklets/voice-processors.js +152 -0
  142. package/src/components/AliaChatMessageList.tsx +0 -532
  143. package/src/components/ThinkingBlock.tsx +0 -43
  144. package/src/hooks/useAliaChat.ts +0 -573
  145. package/src/lib/api-url.ts +0 -2
  146. package/src/lib/catalogue.ts +0 -236
  147. package/src/lib/chat-stream.ts +0 -655
  148. package/src/lib/chat-transport.ts +0 -98
  149. package/src/lib/delegation.ts +0 -34
  150. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,1427 @@
1
+ import { ASK_USER_QUESTION_TOOL } from '../tools';
2
+ import { useOxy } from '@oxy.so/services/ui/client';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+ import { useCallback, useEffect, useRef, useState } from 'react';
5
+ import { useLabels } from '../lib/labels';
6
+ import { type AliaArtifact, useAliaRuntime } from './config';
7
+ import { errorCode, errorMessage as getErrorMessage, errorName, errorStatus } from './error-utils';
8
+ import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
9
+ import { aliaQueryKeys } from './query-keys';
10
+ import { createRandomUuid } from './random-uuid';
11
+ import { type AliaHostTools, HostOutcomeQueue, hostRequest } from './host-tools';
12
+ import { API_ROUTES } from './routes';
13
+ import { createSseFrameReader } from './sse-frame-reader';
14
+ import {
15
+ type ConsultEvent,
16
+ lockedFeatureOf,
17
+ parseConsultEvent,
18
+ parseHostAction,
19
+ } from './stream-events';
20
+ import { hasUsableStreamOutput, type StreamOutputEvidence } from './stream-outcome';
21
+ import { failureDetail, readAliaMeta, type FailedTurn } from './turn-failure';
22
+ import type {
23
+ AgentRun,
24
+ Conversation,
25
+ ConversationsInfiniteData,
26
+ Message,
27
+ SendOptions,
28
+ ToolInvocation,
29
+ } from './types';
30
+ import { UsageLimitError } from './usage-limit-error';
31
+
32
+ export type { ToolInvocation };
33
+ export type { FailedTurn };
34
+
35
+ /**
36
+ * How a send ended.
37
+ *
38
+ * - `sent`: the turn got an answer, or at least real output. It may STILL
39
+ * carry a `failedTurn` — a stream that produced output and then a
40
+ * synthetic tail keeps the output and shows the tail as an error.
41
+ * - `errored`: no answer, and the person's turn is KEPT in the thread with
42
+ * `failedTurn` attached, retry and all. The caller has nothing to restore.
43
+ * - `failed`: the turn was rolled back to what the list was before the send,
44
+ * so the caller hands the text back to the composer. Only the usage-limit
45
+ * errors end this way now: they open a dialog of their own, and a retry
46
+ * would be the wrong offer next to "upgrade" or "wait".
47
+ * - `aborted`: the person stopped it; partial output is theirs to keep.
48
+ */
49
+ export type SendOutcome = 'sent' | 'errored' | 'failed' | 'aborted';
50
+
51
+ /** Shape of an error body thrown by the streaming fetch (rate-limit / credit info). */
52
+ interface ThrownErrorBody {
53
+ code?: string;
54
+ message?: string;
55
+ retryable?: boolean;
56
+ retryAfter?: number;
57
+ /** Alia's run id, on a turn that failed after the stream began. */
58
+ reference?: string;
59
+ suggestedAction?: 'wait' | 'upgrade';
60
+ }
61
+
62
+ /** Structured `error` object the server may embed in a non-OK response / SSE error event. */
63
+ interface StreamErrorObject {
64
+ code?: string;
65
+ message?: string;
66
+ retryable?: boolean;
67
+ retryAfter?: number;
68
+ suggestedAction?: 'wait' | 'upgrade';
69
+ type?: string;
70
+ /** Alia's run id, on a turn that failed (before any output or part-way). */
71
+ reference?: string;
72
+ details?: {
73
+ limitType?: string;
74
+ current?: number;
75
+ limit?: number;
76
+ tier?: string;
77
+ };
78
+ }
79
+
80
+ /** Non-OK response body read from the stream before the SSE loop starts. */
81
+ interface StreamErrorResponse {
82
+ error?: StreamErrorObject | string;
83
+ details?: unknown;
84
+ }
85
+
86
+ /**
87
+ * The artifact a tool result carries, if it carries one: a generated file, or
88
+ * an `artifact` object a tool returned for its own panel.
89
+ */
90
+ export function artifactOf(
91
+ toolCallId: string,
92
+ name: string | undefined,
93
+ output: unknown,
94
+ ): AliaArtifact | null {
95
+ if (output === null || typeof output !== 'object') return null;
96
+ // Structural per-tool output, read the way the tools write it.
97
+ const result = output as Record<string, any>;
98
+ if (name === 'generateFile') {
99
+ const type = result.language ? 'code' : 'markdown';
100
+ return {
101
+ id: toolCallId,
102
+ type,
103
+ content:
104
+ type === 'code'
105
+ ? { language: result.language, code: result.content }
106
+ : { content: result.content },
107
+ title: result.filename || result.title || 'Generated file',
108
+ timestamp: Date.now(),
109
+ };
110
+ }
111
+ if (result.artifact) {
112
+ const a = result.artifact;
113
+ return {
114
+ id: toolCallId,
115
+ type: a.type || 'markdown',
116
+ content: a.data || a.content || a,
117
+ title: a.title || name || 'Artifact',
118
+ timestamp: Date.now(),
119
+ };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ export interface UseConversationStreamOptions {
125
+ /** The conversation the turns belong to; omitted, the server makes the turn's own. */
126
+ conversationId?: string;
127
+ /** The power level (or a device model) every turn is sent with. */
128
+ model?: string;
129
+ /** The agent this conversation is with — its lead — when it is not Alia. */
130
+ agentId?: string | null;
131
+ /**
132
+ * The UI effects the host's client performs (`docs/agent-embedding.md` §2): declared with every
133
+ * request, performed once when the stream asks, and their outcomes sent with the next request.
134
+ */
135
+ hostTools?: AliaHostTools;
136
+ /**
137
+ * What the host app is, for Alia to know where the person is ("Notes — viewing note #42").
138
+ * Sent as the request's leading system message, which the server reads as client context.
139
+ */
140
+ clientContext?: string;
141
+ }
142
+
143
+ /** The words a message holds, as text: a consulted agent's reply is plain text as it streams. */
144
+ function messageWords(message: Message): string {
145
+ return typeof message.content === 'string' ? message.content : '';
146
+ }
147
+
148
+ export function useConversationStream({
149
+ conversationId,
150
+ model,
151
+ agentId,
152
+ clientContext,
153
+ hostTools,
154
+ }: UseConversationStreamOptions = {}) {
155
+ const { apiUrl, streamFetch, host } = useAliaRuntime();
156
+ /** Read at send time and when an action arrives: the host's declaration may change between turns. */
157
+ const hostToolsRef = useRef(hostTools);
158
+ hostToolsRef.current = hostTools;
159
+ /** This conversation's host actions: performed once, their outcomes held until a request carries them. */
160
+ const hostOutcomesRef = useRef(new HostOutcomeQueue());
161
+ const labels = useLabels();
162
+ const [messages, setMessages] = useState<Message[]>([]);
163
+ const [isLoading, setIsLoading] = useState(false);
164
+ const [error, setError] = useState<Error | null>(null);
165
+ const [conversationTitle, setConversationTitle] = useState<string | null>(null);
166
+ /**
167
+ * The agent proposing that the next stretch of the thread start fresh, or
168
+ * `null` for none.
169
+ *
170
+ * The `reason` is the model's own sentence, in the model's own words. It is
171
+ * carried through untouched so the offer can say WHY instead of appearing as
172
+ * a button with no context.
173
+ *
174
+ * At most one is ever in flight: the server bounds it to one per turn and a
175
+ * later one replaces an unanswered earlier one, so there is nothing to
176
+ * collapse here. And nothing has been written when it arrives — the tool that
177
+ * emits it creates nothing — so ignoring it leaves the thread exactly as it
178
+ * was.
179
+ */
180
+ const [suggestedNewConversation, setSuggestedNewConversation] = useState<string | null>(null);
181
+ /** The run a delegated agent started for this conversation (`alia.agent_turn`), or `null`. */
182
+ const [agentRun, setAgentRun] = useState<AgentRun | null>(null);
183
+ /**
184
+ * The turn that got no answer, drawn in the thread with an error and a
185
+ * retry — or `null`. See `turn-failure.ts` for why a
186
+ * failure is kept rather than rolled back, and what the server persisted
187
+ * (nothing) that makes a plain re-send safe.
188
+ */
189
+ const [failedTurn, setFailedTurn] = useState<FailedTurn | null>(null);
190
+ /** What a retry re-sends: the same content and attachments, the same options. */
191
+ const retryRef = useRef<{
192
+ message: Omit<Message, 'id'>;
193
+ options?: SendOptions;
194
+ userMessageId: string;
195
+ } | null>(null);
196
+ /**
197
+ * The connector and skills each user turn was sent with, by its id, so an
198
+ * edit or a regenerate sends it the way it was sent (#608 §6). A message
199
+ * does not carry them and the server does not return them, so this is only
200
+ * known for turns sent while this screen was open.
201
+ */
202
+ const turnOptionsRef = useRef(new Map<string, Pick<SendOptions, 'mcpServerId' | 'skillNames'>>());
203
+ const { oxyServices, user } = useOxy();
204
+ const queryClient = useQueryClient();
205
+ const abortControllerRef = useRef<AbortController | null>(null);
206
+
207
+ // Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
208
+ const pendingContentRef = useRef('');
209
+ const pendingReasoningRef = useRef('');
210
+ /** A consulted agent's streamed words, by the id of the message they belong to. */
211
+ const pendingAgentContentRef = useRef(new Map<string, string>());
212
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
213
+
214
+ const flushPendingUpdates = useCallback(() => {
215
+ const content = pendingContentRef.current;
216
+ const reasoning = pendingReasoningRef.current;
217
+ const agentContent = pendingAgentContentRef.current;
218
+ if (!content && !reasoning && agentContent.size === 0) return;
219
+
220
+ pendingContentRef.current = '';
221
+ pendingReasoningRef.current = '';
222
+ pendingAgentContentRef.current = new Map();
223
+
224
+ setMessages((prev) => {
225
+ const updated = prev.map((message) => {
226
+ const words = agentContent.get(message.id);
227
+ return words === undefined
228
+ ? message
229
+ : { ...message, content: messageWords(message) + words };
230
+ });
231
+ const lastMessage = updated[updated.length - 1];
232
+ if (lastMessage?.role === 'assistant' && (content || reasoning)) {
233
+ const changes: Partial<Message> = {};
234
+ if (content) changes.content = lastMessage.content + content;
235
+ if (reasoning) changes.thinking = (lastMessage.thinking || '') + reasoning;
236
+ updated[updated.length - 1] = { ...lastMessage, ...changes };
237
+ }
238
+ return updated;
239
+ });
240
+ }, []);
241
+
242
+ /** Drop everything still batched: the turn it belonged to is being undone. */
243
+ const dropPendingUpdates = useCallback(() => {
244
+ pendingContentRef.current = '';
245
+ pendingReasoningRef.current = '';
246
+ pendingAgentContentRef.current = new Map();
247
+ if (flushTimerRef.current) {
248
+ clearTimeout(flushTimerRef.current);
249
+ flushTimerRef.current = null;
250
+ }
251
+ }, []);
252
+
253
+ const scheduleFlush = useCallback(() => {
254
+ if (flushTimerRef.current) return;
255
+ flushTimerRef.current = setTimeout(() => {
256
+ flushTimerRef.current = null;
257
+ flushPendingUpdates();
258
+ }, 50);
259
+ }, [flushPendingUpdates]);
260
+
261
+ // Cleanup: flush remaining content and clear timer on unmount
262
+ useEffect(() => {
263
+ return () => {
264
+ flushPendingUpdates();
265
+ if (flushTimerRef.current) {
266
+ clearTimeout(flushTimerRef.current);
267
+ flushTimerRef.current = null;
268
+ }
269
+ };
270
+ }, [flushPendingUpdates]);
271
+
272
+ // Ref to avoid messages in append's dep array (avoids recreation every 50ms during streaming)
273
+ // Synced both via useEffect (for streaming updates) and eagerly in setMessagesAndRef
274
+ // so that setMessages + append in the same tick see the correct history.
275
+ const messagesRef = useRef<Message[]>([]);
276
+ useEffect(() => {
277
+ messagesRef.current = messages;
278
+ }, [messages]);
279
+
280
+ // Wrapper that eagerly syncs messagesRef before React re-renders,
281
+ // so append() called in the same tick reads truncated history (e.g. editMessage).
282
+ const setMessagesAndRef = useCallback((update: Message[] | ((prev: Message[]) => Message[])) => {
283
+ if (typeof update === 'function') {
284
+ setMessages((prev) => {
285
+ const next = update(prev);
286
+ messagesRef.current = next;
287
+ return next;
288
+ });
289
+ } else {
290
+ messagesRef.current = update;
291
+ setMessages(update);
292
+ }
293
+ }, []);
294
+
295
+ /**
296
+ * A different conversation is a different thread: a failure from the last
297
+ * one must not be drawn under a message in this one.
298
+ */
299
+ const conversationRef = useRef(conversationId);
300
+ useEffect(() => {
301
+ setFailedTurn(null);
302
+ retryRef.current = null;
303
+ turnOptionsRef.current.clear();
304
+ // Outcomes answer this conversation's calls. The first turn of a new chat gives it its id,
305
+ // which is the same conversation; any other change is a different one.
306
+ const was = conversationRef.current;
307
+ conversationRef.current = conversationId;
308
+ if (was !== undefined && was !== conversationId) hostOutcomesRef.current.clear();
309
+ }, [conversationId]);
310
+
311
+ /**
312
+ * A different account is a different person: the thread on screen is not
313
+ * theirs.
314
+ *
315
+ * The new-chat screen stays mounted across a sign-out or a switch, and in
316
+ * ghost mode its thread exists nowhere but here — so without this the next
317
+ * person to sit down saw the previous one's conversation, and a reply still
318
+ * streaming kept writing into it. The stream is cut and the thread emptied.
319
+ * Signing IN from signed-out keeps what is there: nobody else owned it.
320
+ */
321
+ const accountId = user?.id ?? null;
322
+ const accountRef = useRef(accountId);
323
+ useEffect(() => {
324
+ const was = accountRef.current;
325
+ accountRef.current = accountId;
326
+ if (was === null || was === accountId) return;
327
+ abortControllerRef.current?.abort();
328
+ abortControllerRef.current = null;
329
+ dropPendingUpdates();
330
+ hostOutcomesRef.current.clear();
331
+ setMessagesAndRef([]);
332
+ setIsLoading(false);
333
+ setError(null);
334
+ setConversationTitle(null);
335
+ setSuggestedNewConversation(null);
336
+ setAgentRun(null);
337
+ setFailedTurn(null);
338
+ retryRef.current = null;
339
+ turnOptionsRef.current.clear();
340
+ }, [accountId, setMessagesAndRef, dropPendingUpdates]);
341
+
342
+ const append = useCallback(
343
+ async (message: Omit<Message, 'id'>, options?: SendOptions): Promise<SendOutcome> => {
344
+ setIsLoading(true);
345
+ setError(null);
346
+ // A new send answers the old failure, whether it is the retry or a fresh
347
+ // message: either way the card comes down.
348
+ setFailedTurn(null);
349
+ retryRef.current = null;
350
+
351
+ // Everything the send is about to change, so a turn that produces no real
352
+ // output can be undone in one step: the user message, the assistant
353
+ // placeholder, and any history editMessage truncated just before this call.
354
+ const snapshot = messagesRef.current;
355
+
356
+ // Only content the model actually produced counts. The server answers a
357
+ // dead provider, a mid-stream break or a global timeout with HTTP 200 and a
358
+ // stand-in message flagged `alia_meta.synthetic` — that is a failed send
359
+ // wearing a reply's clothes.
360
+ const outputEvidence: StreamOutputEvidence = {
361
+ realOutputChars: 0,
362
+ agentOutputChars: 0,
363
+ durableArtifactCount: 0,
364
+ toolInvocationCount: 0,
365
+ };
366
+
367
+ const rollback = (): SendOutcome => {
368
+ // Drop anything still batched first: flushPendingUpdates appends to
369
+ // whichever message is last, so a buffered fragment surviving the restore
370
+ // would land on the previous turn's reply.
371
+ dropPendingUpdates();
372
+ setMessagesAndRef(snapshot);
373
+ return 'failed';
374
+ };
375
+
376
+ /** Keep a half-streamed turn — destroying real output is worse than showing the error. */
377
+ const settleError = (): SendOutcome => {
378
+ if (!hasUsableStreamOutput(outputEvidence)) return rollback();
379
+ settleAssistant('failed');
380
+ return 'sent';
381
+ };
382
+
383
+ /**
384
+ * The server's stand-in for an answer it could not get, if one arrived.
385
+ *
386
+ * Its content is NEVER appended: "all models are busy" rendered under
387
+ * Alia's mark is Alia declining, and it is the one thing this must not
388
+ * read as. The flag is remembered here and answered when the stream ends.
389
+ */
390
+ let syntheticTail: { retryable: boolean; detail?: string } | null = null;
391
+ /** The reply's text as the model produced it: what the host previews when the turn settles. */
392
+ let replyText = '';
393
+
394
+ /**
395
+ * Keep the person's turn, and hang the failure on it.
396
+ *
397
+ * With no real output the empty assistant placeholder comes out — an
398
+ * empty bubble under a thinking indicator would say an answer is still
399
+ * coming — and the user message stays where it was sent, with the error
400
+ * drawn under it. With real output, everything stays and the error is
401
+ * drawn under the answer as its tail. Either way what a retry needs is
402
+ * parked in `retryRef`, and the whole thing is one state update.
403
+ */
404
+ const keepFailedTurn = (retryable: boolean, detail?: string): SendOutcome => {
405
+ const partial = hasUsableStreamOutput(outputEvidence);
406
+ if (partial) {
407
+ // The output is kept, so what is still batched is part of it: an
408
+ // in-stream error frame can land inside one flush window, and dropping
409
+ // the buffer drew the "interrupted" card under an empty bubble.
410
+ flushPendingUpdates();
411
+ } else {
412
+ dropPendingUpdates();
413
+ }
414
+ if (partial) {
415
+ settleAssistant('failed');
416
+ } else {
417
+ // The updater form, on purpose: the user row and the placeholder went
418
+ // in through plain `setMessages` calls that may not have rendered yet
419
+ // when a very fast failure lands, so `messagesRef` can still hold the
420
+ // pre-send snapshot here. `prev` is always the queue's own truth.
421
+ setMessagesAndRef((prev) => prev.filter((m) => m.id !== assistantMessage.id));
422
+ }
423
+ retryRef.current = { message, options, userMessageId: userMessage.id };
424
+ setFailedTurn({
425
+ userMessageId: userMessage.id,
426
+ anchorMessageId: partial ? assistantMessage.id : userMessage.id,
427
+ retryable,
428
+ partial,
429
+ detail,
430
+ });
431
+ return partial ? 'sent' : 'errored';
432
+ };
433
+
434
+ /**
435
+ * Stamped here, not on the way back: a thread left open across midnight has
436
+ * to draw its date line as the turn happens, and the server's own stamp only
437
+ * arrives with the next full load. `POST /conversations` already accepts a
438
+ * client `createdAt`, so this is the value that persists too.
439
+ */
440
+ const userMessage: Message = {
441
+ ...message,
442
+ id: createRandomUuid(),
443
+ createdAt: new Date().toISOString(),
444
+ unsaved: true,
445
+ };
446
+ if (options !== undefined) {
447
+ turnOptionsRef.current.set(userMessage.id, {
448
+ mcpServerId: options.mcpServerId,
449
+ skillNames: options.skillNames,
450
+ });
451
+ }
452
+ // Build from the pre-send snapshot before any await. The optimistic user
453
+ // row and assistant placeholder may reach messagesRef while device info is
454
+ // collected; reading the ref afterwards used to send both plus userMessage
455
+ // again, persisting user -> empty assistant -> duplicate user.
456
+ const history = buildOutboundMessages(snapshot, userMessage);
457
+ const messagesToSend = clientContext
458
+ ? [{ id: 'client-context', role: 'system', content: clientContext }, ...history]
459
+ : history;
460
+ setMessages((prev) => [...prev, userMessage]);
461
+ // The sidebar's row for this agent, immediately — this is the half that
462
+ // makes sending feel like a chat list rather than a form.
463
+ host.onTurnPreview?.(agentId, typeof message.content === 'string' ? message.content : '');
464
+
465
+ // Create assistant message placeholder. `isStreaming` is the turn's own
466
+ // lifecycle stamp: the thought panel reads "still running" from it rather
467
+ // than from whether the message has text yet, and `settleAssistant` below
468
+ // clears it however the stream ends.
469
+ const assistantMessage: Message = {
470
+ id: createRandomUuid(),
471
+ role: 'assistant',
472
+ content: '',
473
+ toolInvocations: [],
474
+ createdAt: new Date().toISOString(),
475
+ isStreaming: true,
476
+ unsaved: true,
477
+ };
478
+ setMessages((prev) => [...prev, assistantMessage]);
479
+ /** How many agents the lead has consulted in this turn: the next one's `agentMessageId` index. */
480
+ let agentAnswers = 0;
481
+ const nextAgentMessageId = (): string => agentMessageId(assistantMessage.id, agentAnswers++);
482
+ /** Each consultation of this turn, by `consultId`: the message its agent writes into. */
483
+ const consults = new Map<string, string>();
484
+ /** Whether `id` names a message this turn wrote: the question, the reply, an agent's answer. */
485
+ const isThisTurn = (id: string): boolean =>
486
+ id === userMessage.id ||
487
+ id === assistantMessage.id ||
488
+ isAgentMessageOf(id, assistantMessage.id);
489
+
490
+ /**
491
+ * End the assistant message's turn, once.
492
+ *
493
+ * Only a message still marked streaming is touched: the error paths settle
494
+ * it as `failed` before returning, and the `finally` that runs after them
495
+ * must not re-settle it as completed or cancelled. Through the updater,
496
+ * so it lands after every batched content flush queued before it.
497
+ */
498
+ const settleAssistant = (outcome: NonNullable<Message['turnOutcome']>): void => {
499
+ setMessages((prev) => {
500
+ if (prev.find((m) => m.id === assistantMessage.id)?.isStreaming !== true) return prev;
501
+ // A turn that completed is one the server has stored, under these ids.
502
+ const stored = outcome === 'completed';
503
+ return prev.map((m) => {
504
+ // A consultation still open when the turn ends ends with it.
505
+ const settles =
506
+ m.id === assistantMessage.id ||
507
+ (m.isStreaming === true && isAgentMessageOf(m.id, assistantMessage.id));
508
+ let next = settles ? { ...m, isStreaming: false, turnOutcome: outcome } : m;
509
+ if (stored && next.unsaved === true && isThisTurn(next.id)) {
510
+ const { unsaved: _unsaved, ...rest } = next;
511
+ next = rest;
512
+ }
513
+ return next;
514
+ });
515
+ });
516
+ };
517
+
518
+ /** What a consult frame does to the thread: the agent's own message, from start to end. */
519
+ const applyConsult = (event: ConsultEvent): void => {
520
+ if (event.type === 'start') {
521
+ if (consults.has(event.consultId)) return;
522
+ const id = nextAgentMessageId();
523
+ consults.set(event.consultId, id);
524
+ const agentMessage: Message = {
525
+ id,
526
+ unsaved: true,
527
+ role: 'assistant',
528
+ content: '',
529
+ toolInvocations: [],
530
+ createdAt: new Date().toISOString(),
531
+ isStreaming: true,
532
+ agentInfo: { ...event.agent },
533
+ };
534
+ // Above the lead's own reply, which is still being written under it.
535
+ setMessages((prev) => {
536
+ const at = prev.findIndex((m) => m.id === assistantMessage.id);
537
+ return at < 0
538
+ ? [...prev, agentMessage]
539
+ : [...prev.slice(0, at), agentMessage, ...prev.slice(at)];
540
+ });
541
+ return;
542
+ }
543
+ const id = consults.get(event.consultId);
544
+ if (id === undefined) return;
545
+ if (event.type === 'delta') {
546
+ if (event.content === '') return;
547
+ outputEvidence.agentOutputChars += event.content.length;
548
+ const map = pendingAgentContentRef.current;
549
+ map.set(id, (map.get(id) ?? '') + event.content);
550
+ scheduleFlush();
551
+ return;
552
+ }
553
+ if (event.type === 'tool') {
554
+ if (event.phase === 'call') outputEvidence.toolInvocationCount += 1;
555
+ setMessages((prev) =>
556
+ prev.map((m) => {
557
+ if (m.id !== id) return m;
558
+ const invocations = [...(m.toolInvocations ?? [])];
559
+ const at = invocations.findIndex((call) => call.toolCallId === event.toolCallId);
560
+ const previous = at < 0 ? undefined : invocations[at];
561
+ const invocation: ToolInvocation = {
562
+ toolCallId: event.toolCallId,
563
+ toolName: event.name,
564
+ state: event.phase,
565
+ args: event.input ?? previous?.args,
566
+ ...(event.phase === 'result' ? { result: event.output } : {}),
567
+ };
568
+ if (at < 0) invocations.push(invocation);
569
+ else invocations[at] = invocation;
570
+ return { ...m, toolInvocations: invocations };
571
+ }),
572
+ );
573
+ return;
574
+ }
575
+ // The end: what was streamed lands first, then the agent's final words, if it sent them.
576
+ flushPendingUpdates();
577
+ if (event.content !== undefined) outputEvidence.agentOutputChars += event.content.length;
578
+ setMessages((prev) =>
579
+ prev.map((m) =>
580
+ m.id !== id
581
+ ? m
582
+ : {
583
+ ...m,
584
+ ...(event.content === undefined ? {} : { content: event.content }),
585
+ isStreaming: false,
586
+ turnOutcome: event.status === 'completed' ? 'completed' : 'failed',
587
+ },
588
+ ),
589
+ );
590
+ };
591
+
592
+ /** The request's own controller: `finally` asks it whether the person stopped the turn. */
593
+ let controller: AbortController | null = null;
594
+ let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
595
+
596
+ try {
597
+ // Collect device info (will be available to AI via tool if needed)
598
+ const deviceInfo = host.deviceInfo ? await host.deviceInfo() : undefined;
599
+
600
+ // Create abort controller for this request
601
+ controller = new AbortController();
602
+ abortControllerRef.current = controller;
603
+
604
+ /**
605
+ * Read at send time rather than closed over: the capability switches
606
+ * live in a menu that stays open across a send, and a value captured
607
+ * when the callback was built would send the state the composer had
608
+ * before the person touched it.
609
+ */
610
+ const { deepResearch: deepResearchMode, webSearch } = host.turnRequest?.() ?? {};
611
+
612
+ // The host's tools, and the outcomes of the actions it performed since the last request.
613
+ const outcomes = hostOutcomesRef.current.pending();
614
+ const hostMember = hostRequest(hostToolsRef.current, outcomes);
615
+
616
+ // Authentication, the preflight refresh and the single 401 replay belong
617
+ // to the linked Oxy client; this layer never reads or writes a bearer.
618
+ linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
619
+ const response = await linked.client.requestAuthenticatedResponse({
620
+ method: 'POST',
621
+ url: API_ROUTES.chat,
622
+ headers: {
623
+ Accept: 'text/event-stream',
624
+ 'Content-Type': 'application/json',
625
+ ...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
626
+ },
627
+ body: JSON.stringify({
628
+ messages: messagesToSend,
629
+ // The reply is stored under the id it is drawn under here, so a vote
630
+ // or a read-aloud on it needs no reload to find it.
631
+ assistantMessageId: assistantMessage.id,
632
+ stream: true,
633
+ ...(conversationId && { conversationId }),
634
+ // Only sent when OFF. `true` is the server's default and every
635
+ // request has behaved that way, so sending it would be noise.
636
+ ...(webSearch === false && { webSearch: false }),
637
+ // The power level (or a device model). Oxy picks the model of that
638
+ // level; the client never names a hosted model.
639
+ ...(model && { model }),
640
+ ...(options?.skillNames?.length ? { skillIds: options.skillNames } : {}),
641
+ ...(agentId && { agentId }),
642
+ ...(deepResearchMode && { deepResearch: true }),
643
+ ...(options?.mcpServerId === undefined ? {} : { mcpServerId: options.mcpServerId }),
644
+ ...(hostMember === undefined ? {} : { host: hostMember }),
645
+ }),
646
+ signal: controller.signal,
647
+ ...(streamFetch ? { fetch: streamFetch } : {}),
648
+ });
649
+
650
+ if (!response.ok) {
651
+ let errorData: StreamErrorResponse | null = null;
652
+ try {
653
+ // A streaming fetch is body-oriented; .json() may not work for error responses.
654
+ // Read the body manually via the ReadableStream reader.
655
+ if (response.body) {
656
+ const errReader = response.body.getReader();
657
+ const { value } = await errReader.read();
658
+ if (value) {
659
+ errorData = JSON.parse(new TextDecoder().decode(value));
660
+ }
661
+ }
662
+ } catch {
663
+ // Best-effort: if the error body isn't readable/JSON, fall through to
664
+ // the generic status-based error message below.
665
+ }
666
+
667
+ // An agent's feature gate refused the turn: the host shows its own upsell, and the
668
+ // person's words go back to the composer for after.
669
+ const lockedFeature = lockedFeatureOf(
670
+ errorData?.error && typeof errorData.error === 'object' ? errorData.error : null,
671
+ );
672
+ if (lockedFeature !== null) {
673
+ host.onFeatureLocked?.(lockedFeature);
674
+ return rollback();
675
+ }
676
+
677
+ // Detect usage limit errors (429 rate limit, 402 insufficient credits, 403 model access)
678
+ if (response.status === 429 || response.status === 402 || response.status === 403) {
679
+ const errObj =
680
+ errorData?.error && typeof errorData.error === 'object' ? errorData.error : null;
681
+ const isModelAccess = response.status === 403 && errObj?.code === 'MODEL_NOT_IN_PLAN';
682
+ const isCredits = response.status === 402 || errObj?.code === 'INSUFFICIENT_CREDITS';
683
+
684
+ if (isModelAccess || isCredits || response.status === 429) {
685
+ throw new UsageLimitError({
686
+ type: isModelAccess ? 'model_access' : isCredits ? 'credits' : 'rate_limit',
687
+ code:
688
+ errObj?.code ||
689
+ (isModelAccess
690
+ ? 'MODEL_NOT_IN_PLAN'
691
+ : isCredits
692
+ ? 'INSUFFICIENT_CREDITS'
693
+ : 'RATE_LIMIT_EXCEEDED'),
694
+ message:
695
+ errObj?.message ||
696
+ (isModelAccess
697
+ ? 'Upgrade your plan to use this model.'
698
+ : isCredits
699
+ ? "You've run out of credits."
700
+ : "You've sent too many messages."),
701
+ retryable: errObj?.retryable ?? (!isCredits && !isModelAccess),
702
+ retryAfterSeconds: errObj?.retryAfter,
703
+ suggestedAction:
704
+ errObj?.suggestedAction || (isCredits || isModelAccess ? 'upgrade' : 'wait'),
705
+ limitType: errObj?.details?.limitType,
706
+ current: errObj?.details?.current,
707
+ limit: errObj?.details?.limit,
708
+ tier: errObj?.details?.tier,
709
+ });
710
+ }
711
+ }
712
+
713
+ // Session expired or signed out mid-request: friendly sign-in prompt
714
+ // instead of the raw server error string.
715
+ if (response.status === 401) {
716
+ throw new Error(labels.signInRequired);
717
+ }
718
+
719
+ // A turn that failed before any output, answered before the stream
720
+ // opened (Alia's 503/500 with `code`, `retryable` and `reference`):
721
+ // the same failed-turn card as the in-stream error frame — the app's
722
+ // own line, with code and reference — never the server's English.
723
+ const failed =
724
+ errorData?.error && typeof errorData.error === 'object' ? errorData.error : null;
725
+ if (failed && typeof failed.reference === 'string') {
726
+ setError(new Error(getErrorMessage(failed) || `Server error (${response.status})`));
727
+ setIsLoading(false);
728
+ return keepFailedTurn(
729
+ failed.retryable !== false,
730
+ failureDetail({
731
+ code: typeof failed.code === 'string' ? failed.code : undefined,
732
+ reference: failed.reference,
733
+ }),
734
+ );
735
+ }
736
+
737
+ // Generic error fallback
738
+ let errorMessage = `Server error (${response.status})`;
739
+ if (errorData) {
740
+ const err = errorData.error;
741
+ if (typeof err === 'string') {
742
+ errorMessage = err;
743
+ } else if (getErrorMessage(err)) {
744
+ errorMessage = getErrorMessage(err);
745
+ } else if (typeof errorData.details === 'string') {
746
+ errorMessage = errorData.details;
747
+ }
748
+ } else {
749
+ errorMessage = response.statusText || errorMessage;
750
+ }
751
+ throw new Error(errorMessage);
752
+ }
753
+
754
+ if (!response.body) {
755
+ throw new Error('No response received from server');
756
+ }
757
+ // The server has the outcomes this request carried: they are its calls' results now.
758
+ hostOutcomesRef.current.delivered(outcomes);
759
+
760
+ const reader = response.body.getReader();
761
+ const decoder = new TextDecoder();
762
+ /**
763
+ * Frame reassembly lives in `sse-frame-reader.ts`.
764
+ *
765
+ * It used to be loose variables here, and that is how the bug happened:
766
+ * `buffer` was declared outside the read loop and survived a chunk
767
+ * boundary, but the current event name was declared INSIDE it and reset
768
+ * on every `reader.read()`. A frame is `event: X\ndata: {…}\n\n`, so any
769
+ * frame split between those two lines lost its name, fell through to the
770
+ * OpenAI-shaped branch below, found no `choices[0]`, and was dropped in
771
+ * silence — most often for the largest payloads, which are the ones that
772
+ * do not fit in one read: `alia.title`, `alia.tool_result`,
773
+ * `alia.plan_preview`, `alia.agent_start`, `alia.host_action`.
774
+ *
775
+ * Two pieces of state that must both outlive a chunk are fields of one
776
+ * object now, and its test feeds a stream split at every byte offset —
777
+ * which is not something a test of this hook could do.
778
+ */
779
+ const sse = createSseFrameReader();
780
+ let lastOutputAt = 0;
781
+
782
+ while (true) {
783
+ const { done, value } = await reader.read();
784
+
785
+ if (done) {
786
+ // Flush any remaining batched content before checking
787
+ flushPendingUpdates();
788
+
789
+ // The server answered with a stand-in, or with nothing usable at
790
+ // all: the turn stays, with the error under it. (With real output
791
+ // AND a synthetic tail, this keeps the output and marks the tail.)
792
+ if (syntheticTail !== null) {
793
+ return keepFailedTurn(syntheticTail.retryable, syntheticTail.detail);
794
+ }
795
+ if (!hasUsableStreamOutput(outputEvidence)) {
796
+ setError(new Error('No response received from AI'));
797
+ return keepFailedTurn(true);
798
+ }
799
+ break;
800
+ }
801
+
802
+ // `{ stream: true }`: a multi-byte character split across two reads
803
+ // decodes to U+FFFD without it.
804
+ const chunk = decoder.decode(value, { stream: true });
805
+
806
+ for (const frame of sse.push(chunk)) {
807
+ const data = frame.data;
808
+ const currentEventType = frame.event;
809
+
810
+ // Skip [DONE] marker
811
+ if (data === '[DONE]') {
812
+ continue;
813
+ }
814
+
815
+ try {
816
+ const parsed = JSON.parse(data);
817
+
818
+ // ── Named SSE events (Alia extensions) ──
819
+ if (currentEventType) {
820
+ switch (currentEventType) {
821
+ case 'alia.reasoning': {
822
+ const content = parsed.content;
823
+ if (content) {
824
+ pendingReasoningRef.current += content;
825
+ scheduleFlush();
826
+ }
827
+ continue;
828
+ }
829
+ case 'alia.tool_result': {
830
+ const { tool_call_id, name, output } = parsed;
831
+ if (tool_call_id) {
832
+ setMessages((prev) => {
833
+ const updated = [...prev];
834
+ const lastMessage = updated[updated.length - 1];
835
+ if (lastMessage?.role === 'assistant') {
836
+ const invocations = [...(lastMessage.toolInvocations || [])];
837
+ const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
838
+ if (idx >= 0) {
839
+ invocations[idx] = {
840
+ ...invocations[idx],
841
+ state: 'result',
842
+ result: output,
843
+ };
844
+ } else {
845
+ invocations.push({
846
+ toolCallId: tool_call_id,
847
+ toolName: name || 'unknown',
848
+ state: 'result',
849
+ result: output,
850
+ });
851
+ }
852
+ updated[updated.length - 1] = {
853
+ ...lastMessage,
854
+ toolInvocations: invocations,
855
+ };
856
+ }
857
+ return updated;
858
+ });
859
+ // The host hears of every finished tool (the memory document
860
+ // may have been rewritten) and of the artifact it produced.
861
+ host.onToolResult?.({ toolCallId: tool_call_id, name, output });
862
+ const artifact = artifactOf(tool_call_id, name, output);
863
+ if (artifact) {
864
+ outputEvidence.durableArtifactCount += 1;
865
+ host.onArtifact?.(artifact);
866
+ }
867
+ }
868
+ continue;
869
+ }
870
+ case 'alia.agent_start':
871
+ case 'alia.agent_delta':
872
+ case 'alia.agent_tool':
873
+ case 'alia.agent_end': {
874
+ const consult = parseConsultEvent(currentEventType, parsed);
875
+ if (consult !== null) applyConsult(consult);
876
+ continue;
877
+ }
878
+ case 'alia.host_action': {
879
+ // Performed here, by the client that declared it, once; its outcome rides
880
+ // the next request. The stream does not wait for it.
881
+ const action = parseHostAction(parsed);
882
+ if (action !== null) {
883
+ void hostOutcomesRef.current.dispatch(action, hostToolsRef.current);
884
+ }
885
+ continue;
886
+ }
887
+ case 'alia.title': {
888
+ if (parsed.title && parsed.conversationId) {
889
+ queryClient.setQueryData(
890
+ aliaQueryKeys.conversations.detail(parsed.conversationId),
891
+ (old: Conversation | undefined) =>
892
+ old ? { ...old, title: parsed.title } : old,
893
+ );
894
+ queryClient.setQueriesData(
895
+ { queryKey: aliaQueryKeys.conversations.all },
896
+ (old: ConversationsInfiniteData | undefined) => {
897
+ if (!old?.pages) return old;
898
+ return {
899
+ ...old,
900
+ pages: old.pages.map((page) => ({
901
+ ...page,
902
+ conversations: page.conversations.map((c) =>
903
+ c.id === parsed.conversationId ? { ...c, title: parsed.title } : c,
904
+ ),
905
+ })),
906
+ };
907
+ },
908
+ );
909
+ setConversationTitle(parsed.title);
910
+ }
911
+ continue;
912
+ }
913
+ case 'alia.research_progress': {
914
+ setMessages((prev) => {
915
+ const updated = [...prev];
916
+ const lastMessage = updated[updated.length - 1];
917
+ if (lastMessage?.role === 'assistant') {
918
+ updated[updated.length - 1] = {
919
+ ...lastMessage,
920
+ researchProgress: {
921
+ phase: parsed.phase,
922
+ message: parsed.message,
923
+ subQuestions:
924
+ parsed.subQuestions || lastMessage.researchProgress?.subQuestions,
925
+ sourcesFound: parsed.sourcesFound,
926
+ currentQuery: parsed.currentQuery,
927
+ iteration: parsed.iteration,
928
+ isComplete: parsed.phase === 'complete',
929
+ // The final event carries the sources; every earlier
930
+ // one carries none, and a progress event after the
931
+ // final one must not erase them.
932
+ sources: parsed.sources ?? lastMessage.researchProgress?.sources,
933
+ totalSearches:
934
+ parsed.totalSearches ?? lastMessage.researchProgress?.totalSearches,
935
+ },
936
+ };
937
+ }
938
+ return updated;
939
+ });
940
+ continue;
941
+ }
942
+ case 'alia.plan_preview': {
943
+ setMessages((prev) => {
944
+ const updated = [...prev];
945
+ const lastMessage = updated[updated.length - 1];
946
+ if (lastMessage?.role === 'assistant') {
947
+ updated[updated.length - 1] = {
948
+ ...lastMessage,
949
+ plan: {
950
+ planId: parsed.planId,
951
+ steps: parsed.steps || [],
952
+ },
953
+ };
954
+ }
955
+ return updated;
956
+ });
957
+ continue;
958
+ }
959
+ case 'alia.context': {
960
+ // What this turn put in the context window, for the usage card.
961
+ host.onContext?.(
962
+ typeof parsed.conversationId === 'string'
963
+ ? parsed.conversationId
964
+ : (conversationId ?? null),
965
+ parsed,
966
+ );
967
+ continue;
968
+ }
969
+ case 'alia.suggest_new_conversation': {
970
+ // A missing or blank reason degrades to an offer without
971
+ // one rather than to an invented one: the sentence belongs
972
+ // to the model, and a plausible substitute would be worse
973
+ // than none.
974
+ setSuggestedNewConversation(
975
+ typeof parsed.reason === 'string' && parsed.reason.trim() !== ''
976
+ ? parsed.reason.trim()
977
+ : '',
978
+ );
979
+ continue;
980
+ }
981
+ case 'alia.agent_turn': {
982
+ if (parsed.turnId) {
983
+ const run = {
984
+ turnId: String(parsed.turnId),
985
+ agentId: String(parsed.agentId ?? agentId ?? ''),
986
+ };
987
+ setAgentRun(run);
988
+ host.onAgentTurn?.({ ...run, conversationId: conversationId ?? null });
989
+ }
990
+ continue;
991
+ }
992
+ default:
993
+ // Unknown named event — skip
994
+ continue;
995
+ }
996
+ }
997
+
998
+ // ── Standard OpenAI data events ──
999
+
1000
+ // Handle structured error events sent via SSE
1001
+ if (parsed.error) {
1002
+ const err = parsed.error;
1003
+ // An agent's feature gate refused the turn before anything was spent: the host
1004
+ // shows its own upsell, and the person's words go back to the composer.
1005
+ const lockedFeature = lockedFeatureOf(err);
1006
+ if (lockedFeature !== null) {
1007
+ setIsLoading(false);
1008
+ abortControllerRef.current?.abort();
1009
+ abortControllerRef.current = null;
1010
+ reader.cancel();
1011
+ host.onFeatureLocked?.(lockedFeature);
1012
+ return settleError();
1013
+ }
1014
+ // Check for usage limit errors (rate limit, credits, model access)
1015
+ if (
1016
+ errorCode(err) === 'MODEL_NOT_IN_PLAN' ||
1017
+ errorCode(err) === 'INSUFFICIENT_CREDITS' ||
1018
+ err.type === 'rate_limit_error'
1019
+ ) {
1020
+ const isRateLimit = err.type === 'rate_limit_error';
1021
+ throw new UsageLimitError({
1022
+ type:
1023
+ errorCode(err) === 'MODEL_NOT_IN_PLAN'
1024
+ ? 'model_access'
1025
+ : errorCode(err) === 'INSUFFICIENT_CREDITS'
1026
+ ? 'credits'
1027
+ : 'rate_limit',
1028
+ code: String(errorCode(err) ?? ''),
1029
+ message: getErrorMessage(err),
1030
+ retryable: isRateLimit,
1031
+ // A limit to wait out (the plan's usage window, the request
1032
+ // rate) carries when to retry, which drives the dialog's
1033
+ // countdown; running out of credits or a model's plan is an
1034
+ // upgrade.
1035
+ retryAfterSeconds:
1036
+ isRateLimit && typeof err.retryAfter === 'number'
1037
+ ? err.retryAfter
1038
+ : undefined,
1039
+ suggestedAction: isRateLimit ? 'wait' : 'upgrade',
1040
+ limitType:
1041
+ typeof err.details?.limitType === 'string'
1042
+ ? err.details.limitType
1043
+ : undefined,
1044
+ current:
1045
+ typeof err.details?.current === 'number' ? err.details.current : undefined,
1046
+ limit: typeof err.details?.limit === 'number' ? err.details.limit : undefined,
1047
+ });
1048
+ }
1049
+
1050
+ // Generic SSE error — stop, and report it IN the thread: the
1051
+ // turn stays with the error under it, so nothing needs a toast.
1052
+ const msg = getErrorMessage(err) || 'Something went wrong. Please try again.';
1053
+ setError(new Error(msg));
1054
+ setIsLoading(false);
1055
+ if (abortControllerRef.current) {
1056
+ abortControllerRef.current.abort();
1057
+ abortControllerRef.current = null;
1058
+ }
1059
+ reader.cancel();
1060
+ // A turn that failed after it started carries a run reference:
1061
+ // its card says code and reference, never the server's English
1062
+ // prose. A refusal without one (a gate, a bad request) keeps its
1063
+ // message, which is the only thing that says what to change.
1064
+ const detail =
1065
+ typeof err.reference === 'string'
1066
+ ? failureDetail({
1067
+ code: typeof err.code === 'string' ? err.code : undefined,
1068
+ reference: err.reference,
1069
+ })
1070
+ : getErrorMessage(err) || undefined;
1071
+ return keepFailedTurn(err.retryable !== false, detail);
1072
+ }
1073
+
1074
+ // Handle usage/credits info (comes at the end of stream).
1075
+ // New format: alia_usage (separate from OpenAI usage), fallback to legacy usage.
1076
+ //
1077
+ // Read BEFORE the choice guard: the server writes it on a chunk
1078
+ // with `choices: []`, which the guard skips — so it was never
1079
+ // read, and neither the balance nor the spending warning reached
1080
+ // the screen.
1081
+ const aliaUsage = parsed.alia_usage || parsed.usage;
1082
+ if (aliaUsage && aliaUsage.credits_remaining !== undefined) {
1083
+ host.onUsage?.(aliaUsage);
1084
+ }
1085
+
1086
+ // Handle OpenAI-compatible format
1087
+ const choice = parsed.choices?.[0];
1088
+ if (!choice) continue;
1089
+
1090
+ const delta = choice.delta;
1091
+ if (!delta) continue;
1092
+
1093
+ // Handle reasoning/thinking content (batched for performance)
1094
+ if (delta.reasoning) {
1095
+ pendingReasoningRef.current += delta.reasoning;
1096
+ scheduleFlush();
1097
+ }
1098
+
1099
+ // Handle text content (batched for performance)
1100
+ if (delta.content) {
1101
+ const meta = readAliaMeta(parsed);
1102
+ if (meta.synthetic) {
1103
+ // Remembered, never rendered — see `syntheticTail`. The
1104
+ // server sends a stop chunk and [DONE] right after, and the
1105
+ // `done` branch turns this into the error under the turn.
1106
+ const detail = failureDetail(meta);
1107
+ syntheticTail = {
1108
+ retryable: meta.retryable,
1109
+ ...(detail === undefined ? {} : { detail }),
1110
+ };
1111
+ } else {
1112
+ outputEvidence.realOutputChars += delta.content.length;
1113
+
1114
+ // A tick for the host (a haptic), throttled by time — per-character
1115
+ // counting fired dozens of native bridge calls per second on
1116
+ // fast streams.
1117
+ const now = Date.now();
1118
+ if (now - lastOutputAt >= 150) {
1119
+ lastOutputAt = now;
1120
+ host.onOutput?.();
1121
+ }
1122
+
1123
+ replyText += delta.content;
1124
+ pendingContentRef.current += delta.content;
1125
+ scheduleFlush();
1126
+ }
1127
+ }
1128
+
1129
+ // Handle tool calls (OpenAI format: delta.tool_calls)
1130
+ if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
1131
+ for (const tc of delta.tool_calls) {
1132
+ const toolCallId = tc.id;
1133
+ const toolName = tc.function?.name;
1134
+ if (!toolCallId || !toolName) continue;
1135
+ outputEvidence.toolInvocationCount += 1;
1136
+ // A question card is the turn's output, even with no sentence before it.
1137
+ if (toolName === ASK_USER_QUESTION_TOOL) outputEvidence.durableArtifactCount += 1;
1138
+
1139
+ let args: Record<string, unknown> | undefined;
1140
+ if (tc.function?.arguments) {
1141
+ try {
1142
+ args = JSON.parse(tc.function.arguments);
1143
+ } catch {
1144
+ args = { _raw: tc.function.arguments };
1145
+ }
1146
+ }
1147
+
1148
+ setMessages((prev) => {
1149
+ const updated = [...prev];
1150
+ const lastMessage = updated[updated.length - 1];
1151
+ if (lastMessage?.role === 'assistant') {
1152
+ const invocations = [...(lastMessage.toolInvocations || [])];
1153
+ const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
1154
+ const invocation: ToolInvocation = {
1155
+ toolCallId,
1156
+ toolName,
1157
+ state: 'call',
1158
+ args,
1159
+ };
1160
+
1161
+ if (idx >= 0) {
1162
+ invocations[idx] = invocation;
1163
+ } else {
1164
+ invocations.push(invocation);
1165
+ }
1166
+
1167
+ updated[updated.length - 1] = {
1168
+ ...lastMessage,
1169
+ toolInvocations: invocations,
1170
+ };
1171
+ }
1172
+ return updated;
1173
+ });
1174
+ }
1175
+ }
1176
+
1177
+ // Handle tool results (custom extension: delta.tool_result)
1178
+ if (delta.tool_result) {
1179
+ const { tool_call_id, name, output } = delta.tool_result;
1180
+ if (tool_call_id) {
1181
+ setMessages((prev) => {
1182
+ const updated = [...prev];
1183
+ const lastMessage = updated[updated.length - 1];
1184
+ if (lastMessage?.role === 'assistant') {
1185
+ const invocations = [...(lastMessage.toolInvocations || [])];
1186
+ const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
1187
+
1188
+ if (idx >= 0) {
1189
+ invocations[idx] = { ...invocations[idx], state: 'result', result: output };
1190
+ } else {
1191
+ invocations.push({
1192
+ toolCallId: tool_call_id,
1193
+ toolName: name || 'unknown',
1194
+ state: 'result',
1195
+ result: output,
1196
+ });
1197
+ }
1198
+
1199
+ updated[updated.length - 1] = {
1200
+ ...lastMessage,
1201
+ toolInvocations: invocations,
1202
+ };
1203
+ }
1204
+ return updated;
1205
+ });
1206
+
1207
+ host.onToolResult?.({ toolCallId: tool_call_id, name, output });
1208
+ const artifact = artifactOf(tool_call_id, name, output);
1209
+ if (artifact) {
1210
+ outputEvidence.durableArtifactCount += 1;
1211
+ host.onArtifact?.(artifact);
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ // Handle error events from server
1217
+ if (parsed.type === 'error') {
1218
+ const errMsg =
1219
+ typeof parsed.error === 'string'
1220
+ ? parsed.error
1221
+ : parsed.error?.message || JSON.stringify(parsed.error);
1222
+ setError(new Error(errMsg));
1223
+ setIsLoading(false);
1224
+
1225
+ // Abort the stream
1226
+ if (abortControllerRef.current) {
1227
+ abortControllerRef.current.abort();
1228
+ abortControllerRef.current = null;
1229
+ }
1230
+
1231
+ // Break out of the streaming loop
1232
+ reader.cancel();
1233
+ return keepFailedTurn(true, errMsg);
1234
+ }
1235
+ } catch (frameError: unknown) {
1236
+ /**
1237
+ * Malformed SSE fragments are expected mid-stream; the next
1238
+ * complete event supersedes them.
1239
+ *
1240
+ * A `UsageLimitError` is NOT one of those. It is thrown
1241
+ * deliberately from the `parsed.error` branch above so the outer
1242
+ * handler can show the upgrade dialog — and it was thrown from
1243
+ * inside this same `try`, so this `catch` ate it. An in-stream
1244
+ * `INSUFFICIENT_CREDITS`, `MODEL_NOT_IN_PLAN` or
1245
+ * `rate_limit_error` therefore did nothing at all: the loop kept
1246
+ * reading, the stream ended, and the user saw a reply that
1247
+ * simply stopped with no error and no way to act on it. (The
1248
+ * generic-error branch beside it escaped only because it uses
1249
+ * `return` rather than `throw`.)
1250
+ */
1251
+ // `instanceof` AND the name, matching the outer handler: Hermes
1252
+ // can break `instanceof` for Error subclasses, and a rethrow that
1253
+ // misses is the same silent swallow this fixes.
1254
+ if (
1255
+ frameError instanceof UsageLimitError ||
1256
+ errorName(frameError) === 'UsageLimitError'
1257
+ ) {
1258
+ throw frameError;
1259
+ }
1260
+ }
1261
+ }
1262
+ }
1263
+
1264
+ return 'sent';
1265
+ } catch (e: unknown) {
1266
+ // Ignore abort errors (user cancelled) — partial output is theirs to keep.
1267
+ if (e instanceof Error && errorName(e) === 'AbortError') {
1268
+ return 'aborted';
1269
+ }
1270
+
1271
+ // UsageLimitError thrown from the 429/402 handler above
1272
+ // Check both instanceof AND name — Hermes can break instanceof for Error subclasses
1273
+ if (e instanceof UsageLimitError || errorName(e) === 'UsageLimitError') {
1274
+ setError(e instanceof Error ? e : new Error(getErrorMessage(e)));
1275
+ return settleError();
1276
+ }
1277
+
1278
+ // A streaming fetch may throw a non-Error object (e.g. the response body)
1279
+ // Try to detect rate limit / credit errors from the thrown object
1280
+ if (e && typeof e === 'object' && !(e instanceof Error)) {
1281
+ const thrown = e as {
1282
+ status?: number;
1283
+ error?: ThrownErrorBody;
1284
+ body?: { error?: ThrownErrorBody };
1285
+ };
1286
+ const status = thrown.status || errorStatus(e);
1287
+ const errBody: ThrownErrorBody | undefined =
1288
+ thrown.error || thrown.body?.error || (thrown as ThrownErrorBody);
1289
+ if (
1290
+ status === 429 ||
1291
+ status === 402 ||
1292
+ errBody?.code === 'RATE_LIMIT_EXCEEDED' ||
1293
+ errBody?.code === 'INSUFFICIENT_CREDITS'
1294
+ ) {
1295
+ const isCredits = status === 402 || errBody?.code === 'INSUFFICIENT_CREDITS';
1296
+ const usageError = new UsageLimitError({
1297
+ type: isCredits ? 'credits' : 'rate_limit',
1298
+ code: errBody?.code || (isCredits ? 'INSUFFICIENT_CREDITS' : 'RATE_LIMIT_EXCEEDED'),
1299
+ message:
1300
+ errBody?.message ||
1301
+ (isCredits ? "You've run out of credits." : "You've sent too many messages."),
1302
+ retryable: errBody?.retryable ?? !isCredits,
1303
+ retryAfterSeconds: errBody?.retryAfter,
1304
+ suggestedAction: errBody?.suggestedAction || (isCredits ? 'upgrade' : 'wait'),
1305
+ });
1306
+ setError(usageError);
1307
+ return settleError();
1308
+ }
1309
+ }
1310
+
1311
+ // Everything else — the network, a 5xx, a 401 — keeps the turn in the
1312
+ // thread with the error under it and a retry beside it. The message is
1313
+ // shown as the card's detail line, not as an answer.
1314
+ const finalError =
1315
+ e instanceof Error
1316
+ ? e
1317
+ : new Error(
1318
+ typeof e === 'string' ? e : getErrorMessage(e) || 'An unexpected error occurred',
1319
+ );
1320
+ setError(finalError);
1321
+ return keepFailedTurn(true, finalError.message);
1322
+ } finally {
1323
+ // Flush any remaining batched content
1324
+ flushPendingUpdates();
1325
+ if (flushTimerRef.current) {
1326
+ clearTimeout(flushTimerRef.current);
1327
+ flushTimerRef.current = null;
1328
+ }
1329
+ abortControllerRef.current = null;
1330
+ linked?.dispose();
1331
+ // A turn that is still streaming here ended without an error path
1332
+ // settling it: on its own, or because `stop()` aborted the controller.
1333
+ // (The error paths abort it too, but they have settled it first.)
1334
+ settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
1335
+ setIsLoading(false);
1336
+ /*
1337
+ * The answer replaces your own line in the sidebar — once per turn rather
1338
+ * than once per token. Read from what this turn received, not from the
1339
+ * rendered messages, which have not caught up with the last batched flush.
1340
+ */
1341
+ if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1342
+ }
1343
+ },
1344
+ [
1345
+ apiUrl,
1346
+ streamFetch,
1347
+ host,
1348
+ labels,
1349
+ oxyServices,
1350
+ queryClient,
1351
+ conversationId,
1352
+ model,
1353
+ agentId,
1354
+ clientContext,
1355
+ scheduleFlush,
1356
+ flushPendingUpdates,
1357
+ dropPendingUpdates,
1358
+ setMessagesAndRef,
1359
+ ],
1360
+ );
1361
+
1362
+ const stop = useCallback(() => {
1363
+ if (abortControllerRef.current) {
1364
+ abortControllerRef.current.abort();
1365
+ abortControllerRef.current = null;
1366
+ }
1367
+ setIsLoading(false);
1368
+ }, []);
1369
+
1370
+ const clearError = useCallback(() => setError(null), []);
1371
+
1372
+ /**
1373
+ * Send the failed turn again: the same content and attachments, the same
1374
+ * options, through the same `append`.
1375
+ *
1376
+ * The thread is cut back to just before the failed user message first —
1377
+ * the same truncation an edit does — so the re-sent turn lands where the
1378
+ * failed one was rather than after it, and the history the server receives
1379
+ * holds the message once. It was never persisted (see `turn-failure.ts`),
1380
+ * so there is no second row to avoid on that side either. Cut from
1381
+ * `messagesRef` directly and set as an array, not through an updater, so
1382
+ * `append`'s snapshot sees the cut regardless of when React runs it.
1383
+ */
1384
+ const retryFailedTurn = useCallback(async (): Promise<SendOutcome> => {
1385
+ const retry = retryRef.current;
1386
+ if (retry === null) return 'failed';
1387
+ const current = messagesRef.current;
1388
+ const idx = current.findIndex((m) => m.id === retry.userMessageId);
1389
+ if (idx >= 0) setMessagesAndRef(current.slice(0, idx));
1390
+ return append(retry.message, retry.options);
1391
+ }, [append, setMessagesAndRef]);
1392
+
1393
+ /** Take the error down without retrying — the thread was cleared, or the person moved on. */
1394
+ const clearFailedTurn = useCallback(() => {
1395
+ setFailedTurn(null);
1396
+ retryRef.current = null;
1397
+ }, []);
1398
+
1399
+ /** Put the offer away. It wrote nothing, so there is nothing else to undo. */
1400
+ const dismissSuggestedNewConversation = useCallback(() => {
1401
+ setSuggestedNewConversation(null);
1402
+ }, []);
1403
+
1404
+ /** The options a user turn was sent with, if it was sent from this screen. */
1405
+ const turnOptionsOf = useCallback(
1406
+ (userMessageId: string) => turnOptionsRef.current.get(userMessageId),
1407
+ [],
1408
+ );
1409
+
1410
+ return {
1411
+ messages,
1412
+ isLoading,
1413
+ error,
1414
+ append,
1415
+ stop,
1416
+ setMessages: setMessagesAndRef,
1417
+ conversationTitle,
1418
+ clearError,
1419
+ suggestedNewConversation,
1420
+ dismissSuggestedNewConversation,
1421
+ agentRun,
1422
+ failedTurn,
1423
+ retryFailedTurn,
1424
+ clearFailedTurn,
1425
+ turnOptionsOf,
1426
+ };
1427
+ }