@lotics/app-sdk 0.102.1 → 0.102.3

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();
@@ -23787,6 +23826,9 @@ var queryAggregateColumnSchema = zod_default.object({
23787
23826
  input_column: zod_default.string().optional().describe(
23788
23827
  "Input column to aggregate. Required for non-count operations; ignored for count."
23789
23828
  ),
23829
+ filter: tableRecordFiltersSchema.optional().describe(
23830
+ "Aggregates only the input rows that match (SQL `FILTER`). Conditions name input columns and take `{{params.x}}`; aggregates with different filters in one node read the input once."
23831
+ ),
23790
23832
  separator: zod_default.string().max(8).optional().describe('string_agg only. Joins the values. Defaults to ", ".'),
23791
23833
  distinct: zod_default.boolean().optional().describe(
23792
23834
  "string_agg only. Collapses repeats \u2014 a group of containers sized 40HC/40HC/20DC aggregates to '20DC, 40HC'. Defaults to true, which is almost always what a summary column wants."
@@ -25313,12 +25355,12 @@ async function evaluateWorkflowExpression(expr2, ctx) {
25313
25355
  }
25314
25356
  function evaluateLambda(params, body, ctx, items) {
25315
25357
  return async (...args) => {
25316
- const frame = {};
25358
+ const frame2 = {};
25317
25359
  for (let i = 0; i < params.length; i++) {
25318
- frame[params[i]] = args[i];
25360
+ frame2[params[i]] = args[i];
25319
25361
  }
25320
25362
  const stack = ctx.lambda_params ?? [];
25321
- const newStack = [...stack, frame];
25363
+ const newStack = [...stack, frame2];
25322
25364
  const innerCtx = {
25323
25365
  ...ctx,
25324
25366
  lambda_params: newStack,
@@ -25401,8 +25443,8 @@ function resolveReadRoot(source, ctx) {
25401
25443
  const stack = ctx.lambda_params;
25402
25444
  if (stack !== void 0) {
25403
25445
  for (let i = stack.length - 1; i >= 0; i--) {
25404
- const frame = stack[i];
25405
- if (source.name in frame) return frame[source.name];
25446
+ const frame2 = stack[i];
25447
+ if (source.name in frame2) return frame2[source.name];
25406
25448
  }
25407
25449
  }
25408
25450
  throw new WorkflowEvalError(
@@ -25414,8 +25456,8 @@ function resolveReadRoot(source, ctx) {
25414
25456
  const stack = ctx.lexical_bindings;
25415
25457
  if (stack !== void 0) {
25416
25458
  for (let i = stack.length - 1; i >= 0; i--) {
25417
- const frame = stack[i];
25418
- if (source.name in frame) return frame[source.name];
25459
+ const frame2 = stack[i];
25460
+ if (source.name in frame2) return frame2[source.name];
25419
25461
  }
25420
25462
  }
25421
25463
  throw new WorkflowEvalError(
@@ -25453,8 +25495,8 @@ function resolveForeachFrame(ctx, name, at2) {
25453
25495
  at: at2
25454
25496
  });
25455
25497
  }
25456
- const frame = resolveNamedForeachFrame(stack, name);
25457
- if (frame !== void 0) return frame;
25498
+ const frame2 = resolveNamedForeachFrame(stack, name);
25499
+ if (frame2 !== void 0) return frame2;
25458
25500
  const known = stack.map((f) => f.name).filter((n) => n !== void 0);
25459
25501
  throw new WorkflowEvalError(
25460
25502
  `Read from ${at2} "${name}", but no enclosing foreach binds that name` + (known.length > 0 ? ` (in scope: ${known.join(", ")})` : ""),
@@ -27104,15 +27146,15 @@ async function predictRead(walk, step) {
27104
27146
  if (typeof id !== "string" || record2 === void 0) return unknownAt(walk.ctx.step_outputs, step.id);
27105
27147
  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
27148
  }
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 });
27149
+ function bindLexical(frame2, name, held2) {
27150
+ if (held2 === UNKNOWN) return unknownAt(frame2, name);
27151
+ Object.defineProperty(frame2, name, { enumerable: true, configurable: true, writable: true, value: held2 });
27110
27152
  }
27111
27153
  function setLexical(walk, name, held2) {
27112
27154
  const stack = walk.ctx.lexical_bindings ?? [];
27113
27155
  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);
27156
+ const frame2 = stack[at2];
27157
+ if (frame2 !== void 0 && Object.prototype.hasOwnProperty.call(frame2, name)) return bindLexical(frame2, name, held2);
27116
27158
  }
27117
27159
  }
27118
27160
  async function block(walk, steps) {
@@ -27179,8 +27221,8 @@ async function run(walk, step) {
27179
27221
  return;
27180
27222
  }
27181
27223
  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)));
27224
+ const frame2 = walk.ctx.lexical_bindings?.at(-1);
27225
+ if (frame2 !== void 0) bindLexical(frame2, step.name, await attempt(walk, expr(step.initial)));
27184
27226
  return;
27185
27227
  }
27186
27228
  case "assign":
@@ -28136,10 +28178,11 @@ function useAgentRun(alias) {
28136
28178
  }
28137
28179
  handleRef.current?.abort();
28138
28180
  runIdRef.current = null;
28181
+ const mock = getMockAgent(alias);
28139
28182
  const inflight = streamLeg(
28140
- (onText) => rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
28183
+ (onText) => mock === null ? rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
28141
28184
  runIdRef.current = runId;
28142
- }),
28185
+ }) : playMockRun(typeof mock === "function" ? mock(input ?? {}) : mock, onText),
28143
28186
  initialAgentRunState()
28144
28187
  );
28145
28188
  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/mutations.md CHANGED
@@ -531,8 +531,9 @@ every read before the request answers:
531
531
  - **Totals.** A `total`, a count per value (`total: { by }`), a group asked with `aggregate` or
532
532
  declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
533
533
  or takes away; a group the last row leaves is gone, and a count per value gains the value a row
534
- first holds. A mean, an extreme, a distinct count, a group by day, a group none of the read's rows
535
- counted in yet, a read whose rows a search or a cap decides, or a row the app never read is left
534
+ first holds. A mean, an extreme, a distinct count, a group by day, an aggregate with its own
535
+ `filter`, a group none of the read's rows counted in yet, a read whose rows a search or a cap
536
+ decides, or a row the app never read is left
536
537
  as the server said it until a read asked after the write answers.
537
538
  - **A refusal takes it back**, and `result` says why, as always. A success keeps it until a read
538
539
  asked after the write settled answers — the stored value then replaces the prediction, whatever
package/docs/queries.md CHANGED
@@ -277,7 +277,7 @@ came from.
277
277
 
278
278
  Full semantics in §8. `by` may be empty (a single-row aggregate); `aggregates` needs ≥ 1 entry.
279
279
  Aggregate operation × input-column type is validated at bind. Grouping collapses rows —
280
- addressing is dropped.
280
+ addressing is dropped. An aggregate's `filter` limits it to the rows that match (§8).
281
281
 
282
282
  ### `window` — aggregate without collapsing
283
283
 
@@ -679,6 +679,25 @@ everything else requires an `input_column` whose type must be compatible — che
679
679
  ¹ opaque `json` columns support only the presence-counting six (`empty`/`filled`/`unique` and
680
680
  their `percent_*` forms).
681
681
 
682
+ **`filter` — aggregate a subset.** Any aggregate, in `group` or `window`, takes a `filter` over its
683
+ input columns — the same tree as a `filter` node, `{{params.x}}` included, with a condition on an
684
+ omitted optional param dropped. The aggregate reads only the rows it matches (SQL `FILTER`), and
685
+ the rest of the node is unaffected, so several figures over one table cost one read of it:
686
+
687
+ ```jsonc
688
+ { "kind": "group", "from": { "kind": "from_table", "table_id": "…" }, "by": [],
689
+ "aggregates": [
690
+ { "output": "open", "type": "number", "operation": "count",
691
+ "filter": { "node_type": "condition", "field_key": "closed_at", "operator": "is_empty" } },
692
+ { "output": "closed_today", "type": "number", "operation": "count",
693
+ "filter": { "node_type": "condition", "field_key": "closed_at", "operator": "on",
694
+ "value": { "type": "period", "period": "day", "boundary": "start", "offset": 0 } } } ] }
695
+ ```
696
+
697
+ A relative date resolves in its field's timezone while the column still names one field — over a
698
+ `from_table`, or a `project` of one. Over a `union` of different tables it names none; count per
699
+ table, then combine.
700
+
682
701
  **`string_agg` — a summary column, not a dataset.** Every other operation counts or reduces to a
683
702
  number; this one joins the values, so a child set answers "which ones?" in the parent row (the
684
703
  sizes on a shipment, the tags on a ticket) without a second query.
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.3",
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": {