@lotics/app-sdk 0.102.1 → 0.102.2

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/AGENTS.md CHANGED
@@ -25,7 +25,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
25
25
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
26
26
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
27
27
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
28
- | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()`, the mock harness (`fixture` + `?__mock=1`), `openExternal`/`openApp`/`downloadFile`, geofencing, the peer dependencies. |
28
+ | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()`, the mock harness (`fixture` + `?__mock=1` — queries, workflows and agents, a mocked agent's run played line by line), `openExternal`/`openApp`/`downloadFile`, geofencing, the peer dependencies. |
29
29
 
30
30
  ## Non-negotiables (each detailed in its doc)
31
31
 
@@ -2,7 +2,8 @@
2
2
  * Folds the AI-SDK UI-message SSE stream into the `UIMessagePart[]` shape
3
3
  * `@lotics/ui` `AgentRun` renders. `ai` is imported for types only, so no `ai`
4
4
  * runtime enters the app bundle; unknown chunk types are ignored. A structured
5
- * agent's `submit_result` input becomes `output`, never a part.
5
+ * agent's `output` is the result the server accepted (`data-run-output`, sent
6
+ * before `finish`); until it arrives, `submit_result`'s input — never a part.
6
7
  */
7
8
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
8
9
  /** An ai-sdk message part — the render model, tool-set-agnostic. */
@@ -12,7 +13,7 @@ export declare const INTERACTIVE_TOOLS: ReadonlySet<string>;
12
13
  export interface AgentRunState {
13
14
  status: "streaming" | "awaiting_input" | "completed" | "error";
14
15
  parts: AgentUIPart[];
15
- /** `submit_result`'s input, never the free text — a consumer reading `output.<field>` would crash on a string. */
16
+ /** The structured result — `submit_result`'s input as it streams, the server's accepted one from `data-run-output` on; never the free text — a consumer reading `output.<field>` would crash on a string. */
16
17
  output?: unknown;
17
18
  error?: string;
18
19
  }
@@ -92,6 +93,7 @@ interface Chunk {
92
93
  output?: unknown;
93
94
  errorText?: string;
94
95
  finishReason?: string;
96
+ data?: unknown;
95
97
  }
96
98
  export declare function reduceAgentChunk(state: AgentRunState, chunk: Chunk): AgentRunState;
97
99
  /** Complete chunks so far and the partial frame to carry forward; `[DONE]` is dropped. */
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export type { ResolvedMember } from "./members.js";
28
28
  export { readSelect } from "./select.js";
29
29
  export type { ResolvedOption } from "./select.js";
30
30
  export type { AppFixture, MockExport, MockQuery, MockQueryCall, MockWorkflow } from "./mock.js";
31
+ export type { MockAgent, MockAgentRun } from "./mock_agent_run.js";
31
32
  export type { AppWorkflows, AppWorkflowResults, AppQueries, AppQueryColumns, AppAgents, AppAgentResults } from "./types.js";
32
33
  export { row, readLinks, readFiles, readLocked, readCreatedAt, readUpdatedAt, PENDING } from "./row.js";
33
34
  export type { ResolvedLink, AppFile } from "./row.js";
package/dist/index.js CHANGED
@@ -59,6 +59,10 @@ function getMockWorkflow(alias) {
59
59
  if (!isMockMode()) return null;
60
60
  return registeredFixture?.workflows?.[alias] ?? null;
61
61
  }
62
+ function getMockAgent(alias) {
63
+ if (!isMockMode()) return null;
64
+ return registeredFixture?.agents?.[alias] ?? null;
65
+ }
62
66
  function getMockExport(alias) {
63
67
  if (!isMockMode()) return null;
64
68
  return registeredFixture?.exports?.[alias] ?? null;
@@ -91,8 +95,8 @@ function reportAppError(kind, error51) {
91
95
  function firstFrame(error51, stack) {
92
96
  const header = String(error51);
93
97
  const frames = stack.startsWith(header) ? stack.slice(header.length) : stack;
94
- const frame = frames.split("\n").find((line) => line.trim() !== "");
95
- return frame === void 0 ? null : withoutOrigins(frame.trim()).slice(0, MESSAGE_LIMIT);
98
+ const frame2 = frames.split("\n").find((line) => line.trim() !== "");
99
+ return frame2 === void 0 ? null : withoutOrigins(frame2.trim()).slice(0, MESSAGE_LIMIT);
96
100
  }
97
101
  function withoutOrigins(text2) {
98
102
  return text2.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, "").replace(/[?#][^\s)]*?(?=(?::\d+){1,2}\)?(?:\s|$)|[\s)]|$)/g, "");
@@ -387,6 +391,8 @@ function reduceAgentChunk(state, chunk) {
387
391
  return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: chunk.output })) };
388
392
  case "tool-output-error":
389
393
  return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-error", input: p.input, errorText: chunk.errorText ?? "The tool failed." })) };
394
+ case "data-run-output":
395
+ return chunk.data !== null && typeof chunk.data === "object" ? { ...state, output: chunk.data } : state;
390
396
  case "error":
391
397
  return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
392
398
  case "abort":
@@ -415,8 +421,8 @@ function parseSseChunks(buffer) {
415
421
  const chunks = [];
416
422
  const frames = buffer.split("\n\n");
417
423
  const rest = frames.pop() ?? "";
418
- for (const frame of frames) {
419
- for (const line of frame.split("\n")) {
424
+ for (const frame2 of frames) {
425
+ for (const line of frame2.split("\n")) {
420
426
  const trimmed = line.startsWith("data:") ? line.slice(5).trim() : "";
421
427
  if (!trimmed || trimmed === "[DONE]") continue;
422
428
  try {
@@ -444,6 +450,39 @@ function landingOf(state) {
444
450
  }
445
451
  }
446
452
 
453
+ // src/mock_agent_run.ts
454
+ var BEAT_MS = 700;
455
+ var frame = (chunk) => `data: ${JSON.stringify(chunk)}
456
+
457
+ `;
458
+ function playMockRun(run2, onText, beat = BEAT_MS) {
459
+ const verdict = run2.error !== void 0 ? frame({ type: "error", errorText: run2.error }) : frame({ type: "data-run-output", data: run2.output ?? {} });
460
+ const beats = [...(run2.steps ?? []).map((line) => frame({ type: "text-delta", delta: `${line}
461
+
462
+ ` })), `${verdict}${frame({ type: "finish" })}`];
463
+ let timer;
464
+ let stop = () => {
465
+ };
466
+ const done = new Promise((resolve) => {
467
+ stop = resolve;
468
+ const play = (at2) => {
469
+ timer = setTimeout(() => {
470
+ onText(beats[at2] ?? "");
471
+ if (at2 === beats.length - 1) resolve();
472
+ else play(at2 + 1);
473
+ }, beat);
474
+ };
475
+ play(0);
476
+ });
477
+ return {
478
+ done,
479
+ abort: () => {
480
+ clearTimeout(timer);
481
+ stop();
482
+ }
483
+ };
484
+ }
485
+
447
486
  // src/store.ts
448
487
  import { useCallback as useCallback2, useEffect as useEffect2, useRef, useSyncExternalStore } from "react";
449
488
  var slots = /* @__PURE__ */ new Map();
@@ -25313,12 +25352,12 @@ async function evaluateWorkflowExpression(expr2, ctx) {
25313
25352
  }
25314
25353
  function evaluateLambda(params, body, ctx, items) {
25315
25354
  return async (...args) => {
25316
- const frame = {};
25355
+ const frame2 = {};
25317
25356
  for (let i = 0; i < params.length; i++) {
25318
- frame[params[i]] = args[i];
25357
+ frame2[params[i]] = args[i];
25319
25358
  }
25320
25359
  const stack = ctx.lambda_params ?? [];
25321
- const newStack = [...stack, frame];
25360
+ const newStack = [...stack, frame2];
25322
25361
  const innerCtx = {
25323
25362
  ...ctx,
25324
25363
  lambda_params: newStack,
@@ -25401,8 +25440,8 @@ function resolveReadRoot(source, ctx) {
25401
25440
  const stack = ctx.lambda_params;
25402
25441
  if (stack !== void 0) {
25403
25442
  for (let i = stack.length - 1; i >= 0; i--) {
25404
- const frame = stack[i];
25405
- if (source.name in frame) return frame[source.name];
25443
+ const frame2 = stack[i];
25444
+ if (source.name in frame2) return frame2[source.name];
25406
25445
  }
25407
25446
  }
25408
25447
  throw new WorkflowEvalError(
@@ -25414,8 +25453,8 @@ function resolveReadRoot(source, ctx) {
25414
25453
  const stack = ctx.lexical_bindings;
25415
25454
  if (stack !== void 0) {
25416
25455
  for (let i = stack.length - 1; i >= 0; i--) {
25417
- const frame = stack[i];
25418
- if (source.name in frame) return frame[source.name];
25456
+ const frame2 = stack[i];
25457
+ if (source.name in frame2) return frame2[source.name];
25419
25458
  }
25420
25459
  }
25421
25460
  throw new WorkflowEvalError(
@@ -25453,8 +25492,8 @@ function resolveForeachFrame(ctx, name, at2) {
25453
25492
  at: at2
25454
25493
  });
25455
25494
  }
25456
- const frame = resolveNamedForeachFrame(stack, name);
25457
- if (frame !== void 0) return frame;
25495
+ const frame2 = resolveNamedForeachFrame(stack, name);
25496
+ if (frame2 !== void 0) return frame2;
25458
25497
  const known = stack.map((f) => f.name).filter((n) => n !== void 0);
25459
25498
  throw new WorkflowEvalError(
25460
25499
  `Read from ${at2} "${name}", but no enclosing foreach binds that name` + (known.length > 0 ? ` (in scope: ${known.join(", ")})` : ""),
@@ -27104,15 +27143,15 @@ async function predictRead(walk, step) {
27104
27143
  if (typeof id !== "string" || record2 === void 0) return unknownAt(walk.ctx.step_outputs, step.id);
27105
27144
  walk.ctx.step_outputs[step.id] = { output: modelled(step.id, { id, table_id: record2.table_id, data: knownCells(walk.input.tables, record2.table_id, record2.cells) }) };
27106
27145
  }
27107
- function bindLexical(frame, name, held2) {
27108
- if (held2 === UNKNOWN) return unknownAt(frame, name);
27109
- Object.defineProperty(frame, name, { enumerable: true, configurable: true, writable: true, value: held2 });
27146
+ function bindLexical(frame2, name, held2) {
27147
+ if (held2 === UNKNOWN) return unknownAt(frame2, name);
27148
+ Object.defineProperty(frame2, name, { enumerable: true, configurable: true, writable: true, value: held2 });
27110
27149
  }
27111
27150
  function setLexical(walk, name, held2) {
27112
27151
  const stack = walk.ctx.lexical_bindings ?? [];
27113
27152
  for (let at2 = stack.length - 1; at2 >= 0; at2--) {
27114
- const frame = stack[at2];
27115
- if (frame !== void 0 && Object.prototype.hasOwnProperty.call(frame, name)) return bindLexical(frame, name, held2);
27153
+ const frame2 = stack[at2];
27154
+ if (frame2 !== void 0 && Object.prototype.hasOwnProperty.call(frame2, name)) return bindLexical(frame2, name, held2);
27116
27155
  }
27117
27156
  }
27118
27157
  async function block(walk, steps) {
@@ -27179,8 +27218,8 @@ async function run(walk, step) {
27179
27218
  return;
27180
27219
  }
27181
27220
  case "let_declare": {
27182
- const frame = walk.ctx.lexical_bindings?.at(-1);
27183
- if (frame !== void 0) bindLexical(frame, step.name, await attempt(walk, expr(step.initial)));
27221
+ const frame2 = walk.ctx.lexical_bindings?.at(-1);
27222
+ if (frame2 !== void 0) bindLexical(frame2, step.name, await attempt(walk, expr(step.initial)));
27184
27223
  return;
27185
27224
  }
27186
27225
  case "assign":
@@ -28136,10 +28175,11 @@ function useAgentRun(alias) {
28136
28175
  }
28137
28176
  handleRef.current?.abort();
28138
28177
  runIdRef.current = null;
28178
+ const mock = getMockAgent(alias);
28139
28179
  const inflight = streamLeg(
28140
- (onText) => rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
28180
+ (onText) => mock === null ? rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
28141
28181
  runIdRef.current = runId;
28142
- }),
28182
+ }) : playMockRun(typeof mock === "function" ? mock(input ?? {}) : mock, onText),
28143
28183
  initialAgentRunState()
28144
28184
  );
28145
28185
  const tracked = inflight.finally(() => {
package/dist/mock.d.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * Design-time fixtures, active only with BOTH `mount(<App />, { fixture })`
3
3
  * and the `?__mock=1` URL param, so demo data in a bundle never answers real
4
4
  * traffic. A mocked workflow never runs, so it writes nothing and nothing is
5
- * drawn ahead of it; a success re-reads every mounted read.
5
+ * drawn ahead of it; a success re-reads every mounted read. A mocked agent is
6
+ * played, never run: its lines, then its result or its error.
6
7
  * `useFileUpload` is not mocked.
7
8
  */
8
9
  import type { QueryAggregate } from "./shared_types.js";
9
10
  import type { UploadedFile, WorkflowResult } from "./hooks.js";
11
+ import type { MockAgent } from "./mock_agent_run.js";
10
12
  import type { ExportCall, QuerySortKey } from "./queries.js";
11
13
  import type { RecordingState } from "./recording_state.js";
12
14
  /** A result, or a function of the inputs — return a slow promise to review the pending state. */
@@ -29,6 +31,8 @@ export type MockExport = (call: ExportCall & {
29
31
  export interface AppFixture {
30
32
  queries?: Record<string, MockQuery>;
31
33
  workflows?: Record<string, MockWorkflow>;
34
+ /** `useAgentRun(alias)` plays the run — or the one made of its input — and never parks. */
35
+ agents?: Record<string, MockAgent>;
32
36
  /** By the query exported. */
33
37
  exports?: Record<string, MockExport>;
34
38
  /** `useRecording(alias)` reads as available; `start`/`stop` change nothing. */
@@ -41,6 +45,8 @@ export declare function hasMockFlag(): boolean;
41
45
  export declare function getMockRows(alias: string, call: MockQueryCall): Array<Record<string, unknown>> | Promise<Array<Record<string, unknown>>> | null;
42
46
  /** Null for an unmocked alias, which executes for real. */
43
47
  export declare function getMockWorkflow(alias: string): MockWorkflow | null;
48
+ /** Null for an unmocked alias, which runs for real. */
49
+ export declare function getMockAgent(alias: string): MockAgent | null;
44
50
  /** Null for an unmocked alias, which the server exports. */
45
51
  export declare function getMockExport(alias: string): MockExport | null;
46
52
  export declare function getMockRecordings(): Record<string, RecordingState> | null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A RUN PLAYED WITHOUT A MODEL, for design time: the lines a run writes as it works, one a beat, then how it ends — the
3
+ * result the server accepted, or the error it failed with — streamed as the server streams a run, so the one reducer
4
+ * that reads a real run reads this one.
5
+ */
6
+ export interface MockAgentRun {
7
+ /** What the run writes as it works, a line a beat. */
8
+ steps?: readonly string[];
9
+ /** A structured agent's result, as the server hands it once it accepted it. */
10
+ output?: Record<string, unknown>;
11
+ /** The run fails with these words instead, its output never sent. */
12
+ error?: string;
13
+ }
14
+ /** A run, or one made of what the run was handed. */
15
+ export type MockAgent = MockAgentRun | ((input: Record<string, unknown>) => MockAgentRun);
16
+ /**
17
+ * Plays `run` into `onText` as `rpcAgentRun` streams a real one: a line each `beat`, then its verdict — the error, or
18
+ * the accepted output — with `finish`, as the server sends them. `abort` stops it where it stands.
19
+ */
20
+ export declare function playMockRun(run: MockAgentRun, onText: (chunk: string) => void, beat?: number): {
21
+ done: Promise<void>;
22
+ abort: () => void;
23
+ };
package/docs/ai.md CHANGED
@@ -65,7 +65,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
65
65
  | `answerChoice` | `(answers: {value, custom}[]) => Promise<AgentRunLanding<TOutput>>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit` yields). Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending — and when the server refuses the answer, in which case the pending question is restored for a retry |
66
66
  | `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed; hand it straight to `@lotics/ui` `AgentRun` |
67
67
  | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
68
- | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
68
+ | `output` | `TOutput \| undefined` | The structured result the server accepted — read it once `status` is `"completed"`. `undefined` for a free-text agent |
69
69
  | `error` | `string \| undefined` | The failure message when `status` is `"error"` |
70
70
 
71
71
  ### The `parts` transcript → `@lotics/ui` `AgentRun`
@@ -78,7 +78,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
78
78
  | `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so `AgentRun` shows it collapsed / revealed on demand. Only produced when the agent's declared model supports thinking (**Sonnet / Opus**, not Haiku) — a Haiku agent never emits reasoning |
79
79
  | `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for `AgentRun`'s on-demand reveal, not for the feed row |
80
80
 
81
- The terminal `submit_result` call is captured into `output`, **not** rendered as a tool part (structured agents therefore have no visible final step). Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
81
+ The terminal `submit_result` call is **not** rendered as a tool part (structured agents therefore have no visible final step); `output` is the result the server accepted, sent as the run finishes. Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
82
82
 
83
83
  ```tsx
84
84
  <AgentRun
@@ -89,7 +89,7 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
89
89
  />
90
90
  ```
91
91
 
92
- `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
92
+ `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand. To see its live, done and failed states without a run, mock the agent (`fixture.agents`, [runtime](./runtime.md)): the run is played through the same stream reader, never billed.
93
93
 
94
94
  `run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
95
95
 
@@ -139,7 +139,7 @@ answer may park again.
139
139
 
140
140
  ### `output` typing and the inner-field caveat
141
141
 
142
- The server validates the agent's submitted result strictly (unknown keys rejected, required fields present, types checked, `select` values bound to the declared options); an invalid submission is rejected back to the agent with the validation errors, and the agent retries. A run only settles **completed** with a result that passed this — so on a completed run, `output.items.map(...)` on a declared array field is safe. What that does **not** guarantee:
142
+ The server validates the agent's submitted result strictly (unknown keys rejected, required fields present, types checked, `select` values bound to the declared options); an invalid submission is rejected back to the agent with the validation errors, and the agent retries. A submission whose JSON arrived broken is not retried: the agent would rewrite it from memory and drop fields, so the run fails instead. A run only settles **completed** with a result that passed this — so on a completed run, `output.items.map(...)` on a declared array field is safe. What that does **not** guarantee:
143
143
 
144
144
  - a field declared `required: false` may be absent;
145
145
  - a `json`-typed output field is passthrough — anything goes inside it;
@@ -147,21 +147,21 @@ The server validates the agent's submitted result strictly (unknown keys rejecte
147
147
  timestamp, an address shape) but not for being the *right* value — a parseable date can still
148
148
  be the wrong date;
149
149
  - every **value** was authored by the model — a schema-valid string can still be semantically wrong;
150
- - one live-stream corner: the hook adopts `output` from the submit call's **arguments as they stream**, before the server-side validation runs. Normally the loud rejection makes the agent retry and the last (valid) submit overwrites it — but a run whose *final* submit was rejected ends the live stream with that invalid attempt still in `output` (the persisted run settles as an error). The review surface below is the backstop.
150
+ - while the run streams, `output` holds the submit call's **arguments** as they arrive, before the server has checked them — only a `completed` run's `output` is the accepted result.
151
151
 
152
152
  Treat `output` as a trusted *shape* carrying untrusted *values*: render it into a review surface and let the user confirm before a workflow commits it. Never write model output straight to records without a review step.
153
153
 
154
- ### Errors — one landing, and one quiet mode
154
+ ### Errors — one landing
155
155
 
156
156
  A run that cannot start (AI-credit quota exhausted, the per-member concurrency cap, an undeclared alias, input validation, a network error) and one that fails mid-run both **resolve** `{kind:"failed", error}`, with `status` `"error"`. Only `answerChoice` rejects, when the server refuses an answer.
157
157
 
158
158
  ```tsx
159
159
  const landing = await recognize.run(input, { sessionId });
160
160
  if (landing.kind === "failed") { /* surface landing.error */ }
161
- else if (landing.kind === "settled" && landing.output === undefined) { /* no result */ }
161
+ else if (landing.kind === "settled") { /* review landing.output */ }
162
162
  ```
163
163
 
164
- A capped run's live message is a generic "The run was stopped." (the persisted run carries "Run exceeded the 20-minute limit."). Two modes are **quiet on the live stream**: a structured agent that finished without ever submitting a result, and a free-text agent that produced no text, land `settled` with no result — only the *persisted* run settles as an error. That is why the example checks `output === undefined`.
164
+ The stream ends on the persisted run's outcome, so the landing and the run never disagree: a structured agent that finished without a valid result, one whose result arrived unreadable, and a free-text agent that produced no text all land `failed`, with the message the run carries (a capped run's "Run exceeded the 20-minute limit." included).
165
165
 
166
166
  ### `cancel` vs `abort`
167
167
 
package/docs/runtime.md CHANGED
@@ -54,6 +54,12 @@ mount(<App />, {
54
54
  ? { status: "error", message: "Row 2 has no customer." }
55
55
  : new Promise((r) => setTimeout(() => r({ status: "success" }), 1200)),
56
56
  },
57
+ agents: {
58
+ // A run's lines, then the result it lands — or a FUNCTION of the run's input.
59
+ recognize: { steps: ["Reading the invoice."], output: { total: 1250 } },
60
+ // Or how it fails.
61
+ triage: { error: "The run ended without a result. Run it again." },
62
+ },
57
63
  },
58
64
  });
59
65
  ```
@@ -62,7 +68,7 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
62
68
  bundle never leaks into normal traffic:
63
69
 
64
70
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
65
- `dist/mock.d.ts` — `{ queries?, workflows?, exports?, recordings? }`).
71
+ `dist/mock.d.ts` — `{ queries?, workflows?, agents?, exports?, recordings? }`).
66
72
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
67
73
  is completely inert.
68
74
 
@@ -99,12 +105,17 @@ pending state. Calling `mount` again (HMR) replaces the registration last-write-
99
105
  **Reach the in-flight state with a function** that resolves on a timer: an app
100
106
  whose only AI surface is a workflow calling `agent(...)` otherwise has no
101
107
  non-billing path to its own thinking / done / error screens.
108
+ - **A mocked agent is PLAYED, never run.** `useAgentRun(alias)` streams the
109
+ fixture's `steps` a line a beat, then lands its `output` — or fails with its
110
+ `error` — through the same stream reader a real run goes through, so the pane's
111
+ live, done and failed states are reviewable with no spend. It never parks on a
112
+ question, and there is no run to cancel on the server.
102
113
  - **A mocked recording is a canned state.** `recordings: { log_visit: { phase:
103
114
  "live", elapsed_seconds: 754, inputs: { site: "rec_1" } } }` makes
104
115
  `useRecording("log_visit")` available with that `state`; `start` and `stop`
105
116
  resolve without recording anything or moving the state.
106
- - **Not mocked:** uploads, `useFieldOptions`, members, comments, agent runs. In
107
- mock mode those still hit the real transport.
117
+ - **Not mocked:** uploads, `useFieldOptions`, members, comments. In mock mode
118
+ those still hit the real transport.
108
119
  - **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
109
120
  screenshot/design-time load emits no events.
110
121
  - The `__mock` param-name prefix is reserved by the SDK; don't use it for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.102.1",
3
+ "version": "0.102.2",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {