@alia.onl/sdk 22.1.1 → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 22.2.0
4
+
5
+ - **Awaited host tools.** `AliaHostToolDefinition.awaitsResult: true` declares a tool whose
6
+ result the model reads in the same turn ("this tab's text"). When the model calls it, the
7
+ runtime runs the host's `execute` once and POSTs `{ toolCallId, outcome, detail?, result? }` at
8
+ once to `/v1/chat/turns/:turnId/host-results` through the authenticated API (with the app
9
+ assertion), instead of queueing the outcome for the next request. `AliaHostOutcome.result`
10
+ carries a string or JSON value of at most `HOST_RESULT_MAX_CHARS` (100,000) characters as JSON;
11
+ a larger one is answered as `failed`. A failed POST is reported to `AliaHostTools.onUndelivered`
12
+ and never retried. `parseHostAction` keeps `awaitsResult` and `turnId`.
13
+ - **Personalization off for one turn.** `AliaTurnRequest.personalization: false` (read at send
14
+ time from `host.turnRequest()`) sends `personalization: false`: Alia reads no memory and offers
15
+ no memory tools for that turn. Nothing is sent otherwise.
16
+ - `awaitsResult` is sent only for a tool that declares it, so existing hosts send the same bytes.
17
+ A host that declares one needs the Alia API receiver for awaited host tools deployed first
18
+ (`docs/agent-embedding.md` §2); an older API refuses the field with `invalid_host`.
19
+
20
+ ## 22.1.2
21
+
22
+ - **Every entry type-checks under `noUncheckedIndexedAccess`.** The SDK ships source, so a
23
+ consumer compiling with `"noUncheckedIndexedAccess": true` (Astro) type-checked the SDK under
24
+ it too and got errors from indexed reads in the runtime, thread, citation and catalogue-tool
25
+ modules (27 from `@alia.onl/sdk/runtime` alone). Each read is now narrowed where the index is
26
+ known to be in range, with a loop over the values where that is simpler. Behaviour is unchanged.
27
+ - `check:entry-types` compiles every entry with `noUncheckedIndexedAccess` on, so CI keeps it so.
28
+
3
29
  ## 22.1.1
4
30
 
5
31
  - A delayed Stop acknowledgement cannot idle a newer turn, replace its sidebar
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "22.1.1",
3
+ "version": "22.2.0",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -554,7 +554,7 @@ export const AmbientField = memo(function AmbientField({
554
554
  key={config.w}
555
555
  config={config}
556
556
  index={index}
557
- color={palette[index]}
557
+ color={palette[index] as string}
558
558
  vw={vw}
559
559
  vh={vh}
560
560
  vmax={vmax}
@@ -121,7 +121,7 @@ function parseBlockData(type: string, match: RegExpExecArray): any {
121
121
  switch (type) {
122
122
  case 'COMPACTLIST': {
123
123
  const title = match[1];
124
- const itemsText = match[2];
124
+ const itemsText = match[2] ?? '';
125
125
  const items = itemsText
126
126
  .split('\n')
127
127
  .filter((line) => line.trim().startsWith('-'))
@@ -140,26 +140,26 @@ function parseBlockData(type: string, match: RegExpExecArray): any {
140
140
  return {
141
141
  type: match[1],
142
142
  title: match[2],
143
- content: match[3].trim(),
143
+ content: (match[3] ?? '').trim(),
144
144
  };
145
145
  }
146
146
  case 'COMPARISON': {
147
147
  const title = match[1];
148
- const content = match[2];
148
+ const content = match[2] ?? '';
149
149
  const leftMatch = content.match(/LEFT:\s*({.*?})/s);
150
150
  const rightMatch = content.match(/RIGHT:\s*({.*?})/s);
151
151
  const conclusionMatch = content.match(/CONCLUSION:\s*(.*?)$/s);
152
152
 
153
153
  return {
154
154
  title,
155
- left: leftMatch ? JSON.parse(leftMatch[1]) : {},
156
- right: rightMatch ? JSON.parse(rightMatch[1]) : {},
157
- conclusion: conclusionMatch ? conclusionMatch[1].trim() : undefined,
155
+ left: leftMatch ? JSON.parse(leftMatch[1] ?? '') : {},
156
+ right: rightMatch ? JSON.parse(rightMatch[1] ?? '') : {},
157
+ conclusion: conclusionMatch ? (conclusionMatch[1] ?? '').trim() : undefined,
158
158
  };
159
159
  }
160
160
  case 'TIMELINE': {
161
161
  const title = match[1];
162
- const itemsText = match[2];
162
+ const itemsText = match[2] ?? '';
163
163
  const items = itemsText
164
164
  .split('\n')
165
165
  .filter((line) => line.trim().startsWith('-'))
@@ -183,7 +183,7 @@ function parseBlockData(type: string, match: RegExpExecArray): any {
183
183
  }
184
184
  case 'CREDIBILITY': {
185
185
  return {
186
- level: parseInt(match[1], 10),
186
+ level: parseInt(match[1] ?? '', 10),
187
187
  source: match[2],
188
188
  };
189
189
  }
@@ -30,7 +30,7 @@ export function ChartRenderer({ data, title }: ChartRendererProps) {
30
30
  labels.map((label, i) => ({ label, value: values[i] || 0 }));
31
31
 
32
32
  if (chartType === 'line' && datasets.length === 1) {
33
- const series = datasets[0];
33
+ const series = datasets[0] as (typeof datasets)[number];
34
34
  return (
35
35
  <LineChartCard
36
36
  title={series.label || title}
@@ -129,7 +129,7 @@ function StepsTab({ steps, lifecycle }: { steps: ThoughtStep[]; lifecycle: TurnL
129
129
  return <EmptyState variant="compact" description={t('thought.noSteps')} />;
130
130
  }
131
131
 
132
- const last = steps[steps.length - 1];
132
+ const last = steps[steps.length - 1] as (typeof steps)[number];
133
133
  const tail = live && (last.type === 'thinking' || last.type === 'writing') ? last : null;
134
134
  const rows = tail === null ? steps : steps.slice(0, -1);
135
135
 
@@ -213,7 +213,7 @@ export const AliaThread = memo(function AliaThread({
213
213
  if (index !== lastReplyIndex || index !== rows.length - 1) return false;
214
214
  if (index < history.length || failedTurn?.anchorMessageId === message.id) return false;
215
215
  for (let i = index - 1; i >= history.length; i--) {
216
- if (rows[i].role === 'user') return true;
216
+ if (rows[i]?.role === 'user') return true;
217
217
  }
218
218
  return false;
219
219
  };
@@ -223,7 +223,7 @@ export const AliaThread = memo(function AliaThread({
223
223
  * an old message belongs to the stretch that ended, not to the one on screen.
224
224
  */
225
225
  const conversationOf = (index: number): string | undefined =>
226
- index < history.length ? history[index].conversationId : conversationId;
226
+ index < history.length ? history[index]?.conversationId : conversationId;
227
227
 
228
228
  // ── Which rows are mounted, and the scroll that goes with them ──
229
229
  /** The row a cursor jump was aimed at, as an index into what is shown, or -1. */
@@ -169,7 +169,7 @@ export function useTimelineWindow({
169
169
  anchor: kept,
170
170
  offset: lastOffset.current ?? 0,
171
171
  atBottom: atBottom.current,
172
- probeId: rows[first],
172
+ probeId: rows[first] as string,
173
173
  });
174
174
  };
175
175
  }, [scrollKey]);
@@ -71,8 +71,8 @@ export function levelFromFrames(frames: readonly number[]): number {
71
71
  if (frames.length === 0) return 0;
72
72
 
73
73
  let sumSquares = 0;
74
- for (let index = 0; index < frames.length; index += 1) {
75
- sumSquares += frames[index] * frames[index];
74
+ for (const frame of frames) {
75
+ sumSquares += frame * frame;
76
76
  }
77
77
  const rms = Math.sqrt(sumSquares / frames.length);
78
78
 
@@ -12,7 +12,7 @@ const CANONICAL_TOOL = /^(?:oxy|self)_([a-z][a-z0-9-]{0,63})__([A-Za-z0-9_-]+)$/
12
12
  /** The app and tool of a canonical catalogue tool name, or `null` for any other name. */
13
13
  export function parseCatalogueTool(toolName: string): { app: string; tool: string } | null {
14
14
  const match = CANONICAL_TOOL.exec(toolName);
15
- return match ? { app: match[1], tool: match[2] } : null;
15
+ return match ? { app: match[1] ?? '', tool: match[2] ?? '' } : null;
16
16
  }
17
17
 
18
18
  /** The renderer key of a tool name (`<app>:<tool>`), or `null` when it is not a catalogue tool. */
@@ -180,7 +180,7 @@ export function splitReferences(
180
180
  const m = REFERENCE_LINE_RE.exec(line);
181
181
  if (m) {
182
182
  flush();
183
- current = { id: Number(m[1]), rest: m[2] };
183
+ current = { id: Number(m[1]), rest: m[2] ?? '' };
184
184
  } else if (current && line.trim().length > 0) {
185
185
  // A legacy entry's URL sits on the indented line after the title.
186
186
  current.rest += ` ${line.trim()}`;
@@ -39,6 +39,11 @@ export interface AliaTurnRequest {
39
39
  /** Only `false` is sent: `true` is the server's default. */
40
40
  webSearch?: boolean;
41
41
  deepResearch?: boolean;
42
+ /**
43
+ * `false` turns personalization off for this turn: Alia reads no memory of the person, recalls
44
+ * nothing and offers no memory tools. The conversation is stored as usual. Only `false` is sent.
45
+ */
46
+ personalization?: false;
42
47
  }
43
48
 
44
49
  /** Why the thread asks its host to show the thought panel. */
@@ -275,13 +275,14 @@ function upsertOnTop(
275
275
  if (moveExisting) {
276
276
  // Remove the conversation from its current position (if it is in any page)
277
277
  for (let i = 0; i < pages.length; i++) {
278
- const existingIndex = pages[i].conversations.findIndex((c) => c.id === row.id);
278
+ const page = pages[i] as ConversationsPage;
279
+ const existingIndex = page.conversations.findIndex((c) => c.id === row.id);
279
280
  if (existingIndex >= 0) {
280
281
  pages[i] = {
281
- ...pages[i],
282
+ ...page,
282
283
  conversations: [
283
- ...pages[i].conversations.slice(0, existingIndex),
284
- ...pages[i].conversations.slice(existingIndex + 1),
284
+ ...page.conversations.slice(0, existingIndex),
285
+ ...page.conversations.slice(existingIndex + 1),
285
286
  ],
286
287
  };
287
288
  break;
@@ -347,7 +348,10 @@ export function useSaveConversation() {
347
348
  id,
348
349
  title: offlineTitle,
349
350
  lastMessage,
350
- createdAt: existingIndex >= 0 ? conversations[existingIndex].createdAt : new Date(),
351
+ createdAt:
352
+ existingIndex >= 0
353
+ ? (conversations[existingIndex] as Conversation).createdAt
354
+ : new Date(),
351
355
  updatedAt: new Date(),
352
356
  messages,
353
357
  };
@@ -7,10 +7,15 @@
7
7
  * the outcome with its NEXT request, where it becomes that call's result. A host tool is never
8
8
  * authority over data: it moves the host's own screen.
9
9
  *
10
- * Two rules hold here, in a queue that knows nothing of React:
10
+ * A tool declared with `awaitsResult: true` is read in the SAME turn instead ("this tab's text",
11
+ * "the open tabs"): the server waits, and this client answers it at once on the turn's
12
+ * `host-results` route with the outcome and the `result` its `execute` returned.
13
+ *
14
+ * Three rules hold here, in a queue that knows nothing of React:
11
15
  * - an action is performed at most once, whatever the stream repeats;
12
16
  * - an outcome stays queued until a request carrying it has been accepted, so a turn that ends
13
- * before its outcomes are sent hands them to the next one.
17
+ * before its outcomes are sent hands them to the next one;
18
+ * - an awaited action's answer is sent once, never queued and never retried.
14
19
  */
15
20
 
16
21
  import type { HostAction } from './stream-events';
@@ -23,6 +28,11 @@ export interface AliaHostToolDefinition {
23
28
  description: string;
24
29
  /** A JSON Schema with an object root. */
25
30
  inputSchema: Record<string, unknown>;
31
+ /**
32
+ * The model reads this tool's result in the same turn: `execute`'s `result` goes back to it at
33
+ * once. Needs an Alia API with awaited host tools (`docs/agent-embedding.md` §2).
34
+ */
35
+ awaitsResult?: boolean;
26
36
  }
27
37
 
28
38
  export const HOST_OUTCOMES = ['applied', 'inline', 'rejected', 'stale', 'failed'] as const;
@@ -33,6 +43,11 @@ export interface AliaHostOutcome {
33
43
  outcome: AliaHostOutcomeKind;
34
44
  /** At most 500 characters; longer is cut. */
35
45
  detail?: string;
46
+ /**
47
+ * What an awaited tool read, for the model: a string or any JSON value, at most
48
+ * {@link HOST_RESULT_MAX_CHARS} characters as JSON. Ignored for a tool that does not await.
49
+ */
50
+ result?: unknown;
36
51
  }
37
52
 
38
53
  /**
@@ -48,6 +63,11 @@ export interface AliaHostTools {
48
63
  definitions: readonly AliaHostToolDefinition[];
49
64
  /** Perform `action` and say what came of it. Called once per action. */
50
65
  execute: (action: HostAction) => Promise<AliaHostOutcome>;
66
+ /**
67
+ * An awaited action's answer could not be delivered (the request failed). It is not retried:
68
+ * the model reads that the app did not answer in time.
69
+ */
70
+ onUndelivered?: (action: HostAction, error: unknown) => void;
51
71
  }
52
72
 
53
73
  /** An outcome as a request carries it. */
@@ -57,6 +77,11 @@ export interface HostOutcomeRecord {
57
77
  detail?: string;
58
78
  }
59
79
 
80
+ /** An awaited action's answer, as `POST /v1/chat/turns/:turnId/host-results` carries it. */
81
+ export interface HostResultRecord extends HostOutcomeRecord {
82
+ result?: unknown;
83
+ }
84
+
60
85
  /** The request's `host` member. */
61
86
  export interface HostRequest {
62
87
  app: string;
@@ -68,6 +93,21 @@ export interface HostRequest {
68
93
 
69
94
  const DETAIL_LIMIT = 500;
70
95
 
96
+ /** The server's bound on an awaited action's `result`, as JSON. */
97
+ export const HOST_RESULT_MAX_CHARS = 100_000;
98
+
99
+ function resultOf(result: unknown): { result?: unknown } | 'too_large' {
100
+ if (result === undefined || result === null) return {};
101
+ let text: string | undefined;
102
+ try {
103
+ text = JSON.stringify(result);
104
+ } catch {
105
+ return {};
106
+ }
107
+ if (text === undefined) return {};
108
+ return text.length > HOST_RESULT_MAX_CHARS ? 'too_large' : { result };
109
+ }
110
+
71
111
  function record(toolCallId: string, outcome: AliaHostOutcome | undefined): HostOutcomeRecord {
72
112
  const kind = HOST_OUTCOMES.includes(outcome?.outcome as AliaHostOutcomeKind)
73
113
  ? (outcome?.outcome as AliaHostOutcomeKind)
@@ -92,25 +132,41 @@ export class HostOutcomeQueue {
92
132
  ): Promise<boolean> {
93
133
  if (this.performed.has(action.toolCallId)) return false;
94
134
  this.performed.add(action.toolCallId);
95
- let outcome: AliaHostOutcome;
96
- if (tools === undefined || app === undefined || action.app !== app) {
97
- outcome = { outcome: 'rejected', detail: 'This app did not declare that tool.' };
98
- } else if (!tools.definitions.some((tool) => tool.name === action.name)) {
99
- outcome = { outcome: 'rejected', detail: `Unknown tool "${action.name}".` };
100
- } else {
101
- try {
102
- outcome = await tools.execute(action);
103
- } catch (error) {
104
- outcome = {
105
- outcome: 'failed',
106
- detail: error instanceof Error ? error.message : 'The app could not do it.',
107
- };
108
- }
109
- }
135
+ const outcome = await perform(action, tools, app);
110
136
  this.ready.push(record(action.toolCallId, outcome));
111
137
  return true;
112
138
  }
113
139
 
140
+ /**
141
+ * Perform an awaited `action` once and `send` its answer at once, with its result. A failed send
142
+ * is reported to the host's `onUndelivered` and not retried. `false` when the action had already
143
+ * been performed.
144
+ */
145
+ async answer(
146
+ action: HostAction,
147
+ tools: AliaHostTools | undefined,
148
+ app: string | undefined,
149
+ send: (answer: HostResultRecord) => Promise<unknown>,
150
+ ): Promise<boolean> {
151
+ if (this.performed.has(action.toolCallId)) return false;
152
+ this.performed.add(action.toolCallId);
153
+ const outcome = await perform(action, tools, app);
154
+ const result = resultOf((outcome as AliaHostOutcome | undefined)?.result);
155
+ const answer: HostResultRecord =
156
+ result === 'too_large'
157
+ ? record(action.toolCallId, {
158
+ outcome: 'failed',
159
+ detail: 'The result was too large to send.',
160
+ })
161
+ : { ...record(action.toolCallId, outcome), ...result };
162
+ try {
163
+ await send(answer);
164
+ } catch (error) {
165
+ tools?.onUndelivered?.(action, error);
166
+ }
167
+ return true;
168
+ }
169
+
114
170
  /** The outcomes ready to send, oldest first. */
115
171
  pending(): HostOutcomeRecord[] {
116
172
  return [...this.ready];
@@ -127,6 +183,27 @@ export class HostOutcomeQueue {
127
183
  }
128
184
  }
129
185
 
186
+ async function perform(
187
+ action: HostAction,
188
+ tools: AliaHostTools | undefined,
189
+ app: string | undefined,
190
+ ): Promise<AliaHostOutcome> {
191
+ if (tools === undefined || app === undefined || action.app !== app) {
192
+ return { outcome: 'rejected', detail: 'This app did not declare that tool.' };
193
+ }
194
+ if (!tools.definitions.some((tool) => tool.name === action.name)) {
195
+ return { outcome: 'rejected', detail: `Unknown tool "${action.name}".` };
196
+ }
197
+ try {
198
+ return await tools.execute(action);
199
+ } catch (error) {
200
+ return {
201
+ outcome: 'failed',
202
+ detail: error instanceof Error ? error.message : 'The app could not do it.',
203
+ };
204
+ }
205
+ }
206
+
130
207
  /**
131
208
  * The `host` member of a request: the chat's `app` and its host tools (none is an empty list),
132
209
  * or nothing for a chat that names no app — Alia's own.
@@ -143,10 +220,12 @@ export function hostRequest(
143
220
  return {
144
221
  app,
145
222
  version: 1,
146
- tools: (tools?.definitions ?? []).map(({ name, description, inputSchema }) => ({
223
+ tools: (tools?.definitions ?? []).map(({ name, description, inputSchema, awaitsResult }) => ({
147
224
  name,
148
225
  description,
149
226
  inputSchema,
227
+ // Only when declared: an API before awaited host tools refuses the unknown field.
228
+ ...(awaitsResult === true ? { awaitsResult: true as const } : {}),
150
229
  })),
151
230
  ...(outcomes.length === 0 ? {} : { outcomes: [...outcomes] }),
152
231
  };
@@ -33,13 +33,14 @@ export {
33
33
  useAgentProfile,
34
34
  } from './agents';
35
35
  export type { AliaAgentFeature, AliaAgentProfile, AliaAgentStarter, FeatureOffer } from './agents';
36
- export { HOST_OUTCOMES, HostOutcomeQueue, hostRequest } from './host-tools';
36
+ export { HOST_OUTCOMES, HOST_RESULT_MAX_CHARS, HostOutcomeQueue, hostRequest } from './host-tools';
37
37
  export type {
38
38
  AliaHostAction,
39
39
  AliaHostOutcome,
40
40
  AliaHostOutcomeKind,
41
41
  AliaHostToolDefinition,
42
42
  AliaHostTools,
43
+ HostResultRecord as AliaHostResultRecord,
43
44
  } from './host-tools';
44
45
  export { FEATURE_ENTITLEMENT_REQUIRED, parseConsultEvent, parseHostAction } from './stream-events';
45
46
  export type { ConsultAgent, ConsultEvent } from './stream-events';
@@ -102,7 +102,7 @@ export function normalizeConversationMessages(messages: readonly Message[]): Mes
102
102
  messages = messages.filter((message) => message.role !== 'system');
103
103
 
104
104
  for (let index = 0; index < messages.length; index += 1) {
105
- const current = messages[index];
105
+ const current = messages[index] as Message;
106
106
  const placeholder = messages[index + 1];
107
107
  const duplicate = messages[index + 2];
108
108
 
@@ -11,6 +11,8 @@
11
11
  */
12
12
  export const API_ROUTES = {
13
13
  chat: '/v1/chat/completions',
14
+ /** The answer to an awaited host action, read by the turn that is waiting for it. */
15
+ hostResults: (turnId: string) => `/v1/chat/turns/${encodeURIComponent(turnId)}/host-results`,
14
16
  conversations: {
15
17
  list: '/v1/conversations',
16
18
  save: '/v1/conversations',
@@ -125,15 +125,29 @@ export interface HostAction {
125
125
  app: string;
126
126
  name: string;
127
127
  input: Record<string, unknown>;
128
+ /**
129
+ * The model is waiting for this action's result within the same turn: its outcome is answered
130
+ * at once on the turn's `host-results` route, never with the next request.
131
+ */
132
+ awaitsResult?: true;
133
+ /** The turn that waits, present exactly when `awaitsResult` is. */
134
+ turnId?: string;
128
135
  }
129
136
 
130
137
  /** The host action an `alia.host_action` frame carries, or `null` when it is not well-formed. */
131
138
  export function parseHostAction(data: unknown): HostAction | null {
132
139
  if (!isObject(data) || data.eventVersion !== 1) return null;
133
- const { toolCallId, app, name, input } = data;
140
+ const { toolCallId, app, name, input, awaitsResult, turnId } = data;
134
141
  if (!isText(toolCallId) || !isText(app) || !isText(name)) return null;
135
142
  if (input !== undefined && !isObject(input)) return null;
136
- return { toolCallId, app, name, input: (input as Record<string, unknown> | undefined) ?? {} };
143
+ if (awaitsResult !== undefined && (awaitsResult !== true || !isText(turnId))) return null;
144
+ return {
145
+ toolCallId,
146
+ app,
147
+ name,
148
+ input: (input as Record<string, unknown> | undefined) ?? {},
149
+ ...(awaitsResult === true ? { awaitsResult: true as const, turnId: turnId as string } : {}),
150
+ };
137
151
  }
138
152
 
139
153
  /** The error code that ends a turn an agent's feature gate refused (§5). */
@@ -718,8 +718,9 @@ export function turnTiming(
718
718
  const index = messages.findIndex((m) => m.id === message.id);
719
719
  let sendStamp: number | null = null;
720
720
  for (let i = index - 1; i >= 0; i -= 1) {
721
- if (messages[i].role === 'user') {
722
- sendStamp = stampToMs(messages[i].createdAt);
721
+ const earlier = messages[i];
722
+ if (earlier?.role === 'user') {
723
+ sendStamp = stampToMs(earlier.createdAt);
723
724
  break;
724
725
  }
725
726
  }
@@ -95,7 +95,7 @@ export function threadHistory(
95
95
  ): ThreadMessage[] {
96
96
  const history: ThreadMessage[] = [];
97
97
  for (let page = pages.length - 1; page >= 0; page -= 1) {
98
- for (const message of pages[page].messages) {
98
+ for (const message of pages[page]?.messages ?? []) {
99
99
  if (message.conversationId === activeConversationId) continue;
100
100
  history.push({ ...message, id: message.id ?? message.cursor });
101
101
  }
@@ -76,7 +76,7 @@ function tailStart(count: number): number {
76
76
  /** The anchor that keeps exactly the rows mounted now, from the row at `start`. */
77
77
  export function pinAnchor(ids: readonly string[], start: number): TimelineAnchor {
78
78
  if (start <= 0 || ids.length === 0) return TOP;
79
- return { kind: 'row', id: ids[start] };
79
+ return { kind: 'row', id: ids[start] as string };
80
80
  }
81
81
 
82
82
  /**
@@ -124,7 +124,7 @@ export function sameTimelineShape(previous: readonly Shape[], next: readonly Sha
124
124
  if (previous === next) return true;
125
125
  if (previous.length !== next.length) return false;
126
126
  for (let i = 0; i < next.length; i += 1) {
127
- if (!sameShape(previous[i], next[i])) return false;
127
+ if (!sameShape(previous[i] as Shape, next[i] as Shape)) return false;
128
128
  }
129
129
  return true;
130
130
  }
@@ -125,8 +125,8 @@ function toPoints(series: readonly PricePoint[], intraday: boolean, locale: stri
125
125
 
126
126
  /** First-to-last change as a ratio, or none for a run that started at nothing. */
127
127
  function runDelta(series: readonly PricePoint[]): number | undefined {
128
- const first = series[0][1];
129
- const last = series[series.length - 1][1];
128
+ const first = (series[0] as PricePoint)[1];
129
+ const last = (series[series.length - 1] as PricePoint)[1];
130
130
  return first === 0 ? undefined : (last - first) / first;
131
131
  }
132
132
 
@@ -189,7 +189,7 @@ export function marketChart(data: MarketCardData, t: Translate, locale: string):
189
189
  id,
190
190
  label: t(`market.range.${id}`),
191
191
  data: toPoints(run, id === '1D', locale),
192
- headline: quoted(data.price) ? data.price : run[run.length - 1][1],
192
+ headline: quoted(data.price) ? data.price : (run[run.length - 1] as PricePoint)[1],
193
193
  delta: id === '1D' && quoted(data.changePct) ? data.changePct / 100 : runDelta(run),
194
194
  });
195
195
  }
@@ -205,7 +205,7 @@ export function marketChart(data: MarketCardData, t: Translate, locale: string):
205
205
  if (intraday.length >= 2) {
206
206
  notes.push(
207
207
  [
208
- `${t('market.open')} ${format(intraday[0])}`,
208
+ `${t('market.open')} ${format(intraday[0] as number)}`,
209
209
  `${t('market.dayHigh')} ${format(Math.max(...intraday))}`,
210
210
  `${t('market.dayLow')} ${format(Math.min(...intraday))}`,
211
211
  ].join(' · '),
@@ -220,7 +220,9 @@ export function marketChart(data: MarketCardData, t: Translate, locale: string):
220
220
  return {
221
221
  title: `${data.name} · ${data.symbol}`,
222
222
  ranges,
223
- defaultRange: ranges.some((r) => r.id === '1D') ? '1D' : ranges[0].id,
223
+ defaultRange: ranges.some((r) => r.id === '1D')
224
+ ? '1D'
225
+ : (ranges[0] as (typeof ranges)[number]).id,
224
226
  format,
225
227
  formatAxisValue: (value) => formatAxis(value, locale),
226
228
  notes,
@@ -275,7 +277,7 @@ export function faircoinChart(
275
277
  id,
276
278
  label: t(`faircoin.range.${id}`),
277
279
  data: toPoints(run, id === '24h', locale),
278
- headline: quoted(data.price) ? data.price : run[run.length - 1][1],
280
+ headline: quoted(data.price) ? data.price : (run[run.length - 1] as PricePoint)[1],
279
281
  delta: id === '24h' && quoted(data.changePct) ? data.changePct / 100 : runDelta(run),
280
282
  });
281
283
  }
@@ -331,7 +333,9 @@ export function faircoinChart(
331
333
  chart: {
332
334
  title: `${t('faircoin.name')} · ${t('faircoin.symbol')}`,
333
335
  ranges,
334
- defaultRange: ranges.some((r) => r.id === '24h') ? '24h' : ranges[0].id,
336
+ defaultRange: ranges.some((r) => r.id === '24h')
337
+ ? '24h'
338
+ : (ranges[0] as (typeof ranges)[number]).id,
335
339
  format,
336
340
  formatAxisValue: (value) => formatAxis(value, locale),
337
341
  notes: [],
@@ -3,6 +3,7 @@ import { useOxy } from '@oxy.so/services/ui/client';
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
4
  import { useCallback, useEffect, useRef, useState } from 'react';
5
5
  import { useLabels } from '../lib/labels';
6
+ import { useAliaApi } from './api';
6
7
  import { useAppAssertionHeaders } from './app-assertion';
7
8
  import { type AliaArtifact, useAliaRuntime } from './config';
8
9
  import {
@@ -162,6 +163,8 @@ export function useConversationStream({
162
163
  hostTools,
163
164
  }: UseConversationStreamOptions = {}) {
164
165
  const { apiUrl, streamFetch, host, app } = useAliaRuntime();
166
+ /** Where an awaited host action's answer goes: the same account and app assertion as the turn. */
167
+ const api = useAliaApi();
165
168
  /** Read at send time and when an action arrives: the host's declaration may change between turns. */
166
169
  const hostToolsRef = useRef(hostTools);
167
170
  hostToolsRef.current = hostTools;
@@ -658,7 +661,11 @@ export function useConversationStream({
658
661
  * when the callback was built would send the state the composer had
659
662
  * before the person touched it.
660
663
  */
661
- const { deepResearch: deepResearchMode, webSearch } = host.turnRequest?.() ?? {};
664
+ const {
665
+ deepResearch: deepResearchMode,
666
+ webSearch,
667
+ personalization,
668
+ } = host.turnRequest?.() ?? {};
662
669
 
663
670
  // The host's tools, and the outcomes of the actions it performed since the last request.
664
671
  const outcomes = hostOutcomesRef.current.pending();
@@ -688,6 +695,8 @@ export function useConversationStream({
688
695
  // Only sent when OFF. `true` is the server's default and every
689
696
  // request has behaved that way, so sending it would be noise.
690
697
  ...(webSearch === false && { webSearch: false }),
698
+ // Only sent when OFF, like web search: absent is the server's default.
699
+ ...(personalization === false && { personalization: false }),
691
700
  // The power level (or a device model). Oxy picks the model of that
692
701
  // level; the client never names a hosted model.
693
702
  ...(model && { model }),
@@ -897,7 +906,7 @@ export function useConversationStream({
897
906
  const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
898
907
  if (idx >= 0) {
899
908
  invocations[idx] = {
900
- ...invocations[idx],
909
+ ...(invocations[idx] as ToolInvocation),
901
910
  state: 'result',
902
911
  result: output,
903
912
  };
@@ -939,13 +948,28 @@ export function useConversationStream({
939
948
  // Performed here, by the client that declared it, once; its outcome rides
940
949
  // the next request. The stream does not wait for it.
941
950
  const action = parseHostAction(parsed);
942
- if (action !== null) {
943
- void hostOutcomesRef.current.dispatch(
951
+ if (action === null) continue;
952
+ if (action.awaitsResult && action.turnId !== undefined) {
953
+ // The turn waits for this one: its answer goes back at once, through the
954
+ // same authenticated API (and app assertion) the turn's account uses.
955
+ const turnSignal = controller.signal;
956
+ const route = API_ROUTES.hostResults(action.turnId);
957
+ void hostOutcomesRef.current.answer(
944
958
  action,
945
959
  hostToolsRef.current,
946
960
  appRef.current,
961
+ async (answer) => {
962
+ if (turnSignal.aborted) throw new Error('The turn ended');
963
+ await api.post(route, answer, { signal: turnSignal });
964
+ },
947
965
  );
966
+ continue;
948
967
  }
968
+ void hostOutcomesRef.current.dispatch(
969
+ action,
970
+ hostToolsRef.current,
971
+ appRef.current,
972
+ );
949
973
  continue;
950
974
  }
951
975
  case 'alia.title': {
@@ -1254,7 +1278,11 @@ export function useConversationStream({
1254
1278
  const idx = invocations.findIndex((t) => t.toolCallId === tool_call_id);
1255
1279
 
1256
1280
  if (idx >= 0) {
1257
- invocations[idx] = { ...invocations[idx], state: 'result', result: output };
1281
+ invocations[idx] = {
1282
+ ...(invocations[idx] as ToolInvocation),
1283
+ state: 'result',
1284
+ result: output,
1285
+ };
1258
1286
  } else {
1259
1287
  invocations.push({
1260
1288
  toolCallId: tool_call_id,
@@ -1413,6 +1441,7 @@ export function useConversationStream({
1413
1441
  }
1414
1442
  },
1415
1443
  [
1444
+ api,
1416
1445
  apiUrl,
1417
1446
  streamFetch,
1418
1447
  host,
@@ -435,7 +435,8 @@ export function useConversationThread({
435
435
  (assistantMessageId: string): Message | undefined => {
436
436
  const idx = messages.findIndex((msg) => msg.id === assistantMessageId);
437
437
  for (let i = idx - 1; i >= 0; i--) {
438
- if (messages[i].role === 'user') return messages[i];
438
+ const earlier = messages[i];
439
+ if (earlier?.role === 'user') return earlier;
439
440
  }
440
441
  return undefined;
441
442
  },