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