@lotics/app-sdk 0.61.1 → 0.62.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/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ signature; open the file.**
21
21
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
22
22
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
23
23
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
24
- | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`** (`run_app_query` / `run_app_workflow`); the raw record read/write tools are rejected. |
24
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`** (`run_app_query` / `run_app_workflow`); the raw record read/write tools are rejected., a leg's `AgentRunLanding` (`settled`/`parked`/`failed`/`aborted` — never a bare `undefined`) |
25
25
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
26
26
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
27
27
 
@@ -145,4 +145,56 @@ export declare function parseSseChunks(buffer: string): {
145
145
  chunks: Chunk[];
146
146
  rest: string;
147
147
  };
148
+ /**
149
+ * How one run LEG ended — the whole answer, in the resolved value.
150
+ *
151
+ * A leg used to resolve `TOutput | undefined`, and `undefined` meant three
152
+ * different things: the run parked on a question, the run died, or the client
153
+ * stopped listening. Telling them apart needed the hook's state, which has not
154
+ * committed yet when the promise resolves — so every consumer raced, and none
155
+ * could win. Minh Tín's app lost live `awaiting_input` runs to exactly that: the
156
+ * dialog closed over a healthy, answerable row and the run expired on its TTL.
157
+ *
158
+ * Discriminating here makes the race unrepresentable rather than guarded. A run
159
+ * FAILURE is data (`failed`) — the caller needs no try/catch for it.
160
+ *
161
+ * The promise still REJECTS for the two cases that are not run outcomes at all:
162
+ * API misuse (`answerChoice` with nothing pending), and a refused answer (400
163
+ * invalid / 409 raced cancel), where the run stays PARKED and answerable — a
164
+ * landing there would report `parked` and swallow the reason the answer bounced.
165
+ */
166
+ export type AgentRunLanding<TOutput> =
167
+ /** The leg finished. `output` is the structured result (absent for a free-text
168
+ * agent, where `text` is the result). */
169
+ {
170
+ kind: "settled";
171
+ output?: TOutput;
172
+ text: string;
173
+ }
174
+ /** Parked on a question. `pendingChoice` carries it and `answerChoice`
175
+ * continues — there is nothing for the caller to do here, which is the point:
176
+ * it is no longer indistinguishable from a dead run. */
177
+ | {
178
+ kind: "parked";
179
+ }
180
+ /** The run failed and its error is user-facing. */
181
+ | {
182
+ kind: "failed";
183
+ error: string;
184
+ }
185
+ /** The CLIENT stopped listening (`abort`, unmount, a newer run replacing this
186
+ * one). The run itself keeps executing server-side and lands in the session
187
+ * history — never surface this as a failure. */
188
+ | {
189
+ kind: "aborted";
190
+ };
191
+ /** The client stopped listening; the run itself continues server-side. Shared so
192
+ * the four exit points cannot drift, and so the literal keeps its type. */
193
+ export declare const ABORTED: AgentRunLanding<never>;
194
+ /** All prose parts concatenated — a free-text agent's actual result. Shared so
195
+ * the hook's `text` and a landing's can never disagree about what was said. */
196
+ export declare function proseOf(parts: readonly AgentUIPart[]): string;
197
+ /** The landing a settled accumulator describes. One place decides, so `run` and
198
+ * `answerChoice` can never disagree about what an outcome was called. */
199
+ export declare function landingOf(state: AgentRunState): AgentRunLanding<unknown>;
148
200
  export {};
@@ -283,3 +283,32 @@ export function parseSseChunks(buffer) {
283
283
  }
284
284
  return { chunks, rest };
285
285
  }
286
+ /** The client stopped listening; the run itself continues server-side. Shared so
287
+ * the four exit points cannot drift, and so the literal keeps its type. */
288
+ export const ABORTED = { kind: "aborted" };
289
+ /** All prose parts concatenated — a free-text agent's actual result. Shared so
290
+ * the hook's `text` and a landing's can never disagree about what was said. */
291
+ export function proseOf(parts) {
292
+ return parts.reduce((acc, p) => (p.type === "text" ? acc + p.text : acc), "");
293
+ }
294
+ /** The landing a settled accumulator describes. One place decides, so `run` and
295
+ * `answerChoice` can never disagree about what an outcome was called. */
296
+ export function landingOf(state) {
297
+ switch (state.status) {
298
+ case "awaiting_input":
299
+ return { kind: "parked" };
300
+ case "error":
301
+ return { kind: "failed", error: state.error ?? "The run failed." };
302
+ case "completed":
303
+ return { kind: "settled", output: state.output, text: proseOf(state.parts) };
304
+ case "streaming":
305
+ // A leg that is still streaming has not landed. Every internal caller has
306
+ // already resolved this state — by polling the persisted row — before
307
+ // asking, so this branch is unreachable there. It is named rather than
308
+ // left to a fallthrough because the fallthrough answered "settled", which
309
+ // would report a live run as finished and hand a consumer a partial
310
+ // `output` as if it were the result. Unconfirmed is the truthful answer,
311
+ // and it matches what the poll path says for the same situation.
312
+ return { kind: "failed", error: "The run has not finished — its result cannot be confirmed yet." };
313
+ }
314
+ }
@@ -1,10 +1,10 @@
1
1
  import { type ImageFidelity } from "./upload/optimize.js";
2
2
  import { type AiContextValue } from "./rpc.js";
3
- import { type AgentUIPart, type PendingChoice } from "./agent_stream.js";
3
+ import { type AgentUIPart, type PendingChoice, type AgentRunLanding } from "./agent_stream.js";
4
4
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
5
5
  import type { ResolvedMember } from "./members.js";
6
6
  import type { ResolvedOption } from "./select.js";
7
- export type { AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput } from "./agent_stream.js";
7
+ export type { AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, AgentRunLanding } from "./agent_stream.js";
8
8
  export { buildChoiceOutput } from "./agent_stream.js";
9
9
  /** Fields shared by every query hook's return value. */
10
10
  interface QueryStateBase {
@@ -472,10 +472,12 @@ export interface AgentRunOptions {
472
472
  }
473
473
  /** The live state + controls returned by `useAgentRun`. */
474
474
  export interface UseAgentRun<TInput, TOutput> {
475
- /** Start a run — streams progress into this hook's state and resolves to the
476
- * structured output (undefined for a free-text or failed run). Calling again
477
- * aborts any run still in flight. */
478
- run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
475
+ /** Start a run — streams progress into this hook's state and resolves with how
476
+ * the leg ENDED (`settled` / `parked` / `failed` / `aborted`). Read `kind`
477
+ * rather than the hook's state: this value is a snapshot at settle, whereas
478
+ * the state has not committed yet when the promise resolves. SINGLE-FLIGHT —
479
+ * a second call while one is streaming joins the first (see `replace`). */
480
+ run: (input: TInput, opts: AgentRunOptions) => Promise<AgentRunLanding<TOutput>>;
479
481
  /** Stop listening locally (no server effect) — the run keeps executing
480
482
  * server-side and its result lands in the session history. Used on unmount. */
481
483
  abort: () => void;
@@ -497,12 +499,13 @@ export interface UseAgentRun<TInput, TOutput> {
497
499
  /** Answer the pending ask and CONTINUE the run — one `{value, custom}` per
498
500
  * question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit`
499
501
  * yields). Streams the continuation into the same `parts` and resolves like
500
- * `run` (the structured output, or `undefined`). Rejects when nothing is
501
- * pending. */
502
+ * `run` a landing, which may be `parked` again for a follow-up ask. Rejects
503
+ * when nothing is pending, or when the server refuses the answer (the run
504
+ * stays parked; show the error and keep the wizard open). */
502
505
  answerChoice: (answers: {
503
506
  value: string;
504
507
  custom: boolean;
505
- }[]) => Promise<TOutput | undefined>;
508
+ }[]) => Promise<AgentRunLanding<TOutput>>;
506
509
  /** The agent's ANSWER prose (every `text` part concatenated), accumulating live —
507
510
  * excludes thinking (`reasoning` is its own part). For a FREE-TEXT agent this IS
508
511
  * the result; a structured agent's result is `output`. Derived from `parts`. */
package/dist/src/hooks.js CHANGED
@@ -20,7 +20,7 @@ import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
20
20
  import useSWR from "swr";
21
21
  import useSWRInfinite from "swr/infinite";
22
22
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
23
- import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, } from "./agent_stream.js";
23
+ import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
24
24
  import { getMockRows, hasMockFlag } from "./mock.js";
25
25
  import { captureAppEvent } from "./analytics.js";
26
26
  export { buildChoiceOutput } from "./agent_stream.js";
@@ -536,7 +536,7 @@ export function useAgentRun(alias) {
536
536
  return handle.done
537
537
  .then(async () => {
538
538
  if (aborted)
539
- return undefined;
539
+ return ABORTED;
540
540
  // A clean stream end WITHOUT a `finish` frame is a truncation, not a
541
541
  // completion — an edge can close a long SSE gracefully mid-run (seen
542
542
  // in production on ~2-minute runs), swallowing the frames that carry
@@ -551,7 +551,7 @@ export function useAgentRun(alias) {
551
551
  ? await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current)
552
552
  : null;
553
553
  if (aborted)
554
- return undefined;
554
+ return ABORTED;
555
555
  // No settled row after the full poll window means the run's status
556
556
  // could NOT be confirmed (an orphan the server's reaper hasn't
557
557
  // repaired yet, or no run id ever arrived) — that is an error, never
@@ -574,11 +574,11 @@ export function useAgentRun(alias) {
574
574
  has_output: acc.output !== undefined,
575
575
  });
576
576
  }
577
- return acc.output;
577
+ return landingOf(acc);
578
578
  })
579
579
  .catch(async (err) => {
580
580
  if (aborted)
581
- return undefined;
581
+ return ABORTED;
582
582
  // The continue request itself was rejected — the server never resumed
583
583
  // the run (400 invalid answer, 409 raced cancel/expiry, network at
584
584
  // connect). Restore the pre-answer parked state and surface the error;
@@ -594,7 +594,7 @@ export function useAgentRun(alias) {
594
594
  if (runId) {
595
595
  const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
596
596
  if (aborted)
597
- return undefined;
597
+ return ABORTED;
598
598
  if (settled) {
599
599
  acc = adoptGuarded(acc, settled);
600
600
  safeSetState(acc);
@@ -610,11 +610,14 @@ export function useAgentRun(alias) {
610
610
  has_output: acc.output !== undefined,
611
611
  });
612
612
  if (settled)
613
- return acc.output;
613
+ return landingOf(acc);
614
614
  }
615
+ // A run failure is DATA — the landing carries it, so no consumer needs
616
+ // try/catch to tell "the run died" from "the run parked". Only API
617
+ // MISUSE still rejects (answering when nothing is pending).
615
618
  acc = { ...acc, status: "error", error: err.message };
616
619
  safeSetState(acc);
617
- throw err;
620
+ return landingOf(acc);
618
621
  });
619
622
  }, [safeSetState]);
620
623
  const run = useCallback((input, opts) => {
@@ -679,7 +682,7 @@ export function useAgentRun(alias) {
679
682
  // `parts` is the source of truth; `text` (all prose concatenated) is the derived
680
683
  // answer view.
681
684
  const parts = state?.parts ?? EMPTY_PARTS;
682
- const text = useMemo(() => parts.reduce((acc, p) => (p.type === "text" ? acc + p.text : acc), ""), [parts]);
685
+ const text = useMemo(() => proseOf(parts), [parts]);
683
686
  return {
684
687
  run,
685
688
  abort,
package/docs/ai.md CHANGED
@@ -56,12 +56,12 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
56
56
 
57
57
  | Member | Type | What it is |
58
58
  |---|---|---|
59
- | `run` | `(input, { sessionId, replace? }) => Promise<TOutput \| undefined>` | Start a run. Streams progress into the hook's state and resolves to the structured output (`undefined` for a free-text or failed run). **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one (each dedup emits the `app_agent_run_deduped` analytics event). Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
59
+ | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves (that race closed dialogs over live, answerable runs). A run failure is DATA here, so no try/catch is needed for it. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one (each dedup emits the `app_agent_run_deduped` analytics event). Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
60
60
  | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
61
61
  | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
62
62
  | `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
63
63
  | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
64
- | `answerChoice` | `(answers: {value, custom}[]) => Promise<TOutput \| undefined>` | 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 |
64
+ | `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 |
65
65
  | `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` |
66
66
  | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
67
67
  | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
@@ -125,8 +125,9 @@ const run = useAgentRun("importer");
125
125
  ```
126
126
 
127
127
  The ask renders in the feed as a settled tool row once answered (the answer rides its
128
- on-demand reveal). `run()`'s promise resolves `undefined` when the run parks — the
129
- continuation's promise (from `answerChoice`) carries the final output. The question is as
128
+ on-demand reveal). `run()` resolves `{kind:"parked"}` when the run parks — nothing for you
129
+ to do there, the wizard renders off `pendingChoice` and the continuation's landing (from
130
+ `answerChoice`) carries the final output, or `parked` again for a follow-up ask. The question is as
130
131
  connection-decoupled as the run: a dropped stream can't lose it — the hook's recovery poll
131
132
  rebuilds the pending question from the persisted run, so `awaiting_input` always yields an
132
133
  answerable `pendingChoice` (the one unrecoverable corner surfaces a retryable `error`, never
@@ -382,3 +383,4 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
382
383
  | `askAi` | `@lotics/app-sdk` 0.45 |
383
384
  | `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
384
385
  | The agent asks back (`pendingChoice`/`answerChoice`, `awaiting_input`) | `@lotics/app-sdk` 0.55 |
386
+ | A leg resolves an `AgentRunLanding` (`settled`/`parked`/`failed`/`aborted`) instead of `TOutput \| undefined` | `@lotics/app-sdk` 0.62 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.61.1",
3
+ "version": "0.62.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {