@lotics/app-sdk 0.52.0 → 0.52.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.
@@ -68,6 +68,24 @@ export interface AgentRunState {
68
68
  output?: unknown;
69
69
  error?: string;
70
70
  }
71
+ /** A settled run row, as the poll endpoint returns it. */
72
+ export interface SettledAgentRun {
73
+ status: string;
74
+ output?: unknown;
75
+ error_message?: string | null;
76
+ }
77
+ /**
78
+ * Fold a POLLED settled run row into the stream-accumulated state — the shared
79
+ * adoption step for BOTH recovery paths (a dropped stream, and a stream that
80
+ * ended cleanly WITHOUT a `finish` frame — an edge can close a long SSE
81
+ * gracefully mid-run, which looks like completion but is a truncation).
82
+ *
83
+ * The row is the source of truth for status and the STRUCTURED output. A
84
+ * free-text run's row `output` is its prose string — that never enters
85
+ * `state.output` (a structured consumer reads `output.<field>`; a stray string
86
+ * would crash it). The free-text answer stays in the transcript text.
87
+ */
88
+ export declare function adoptSettledRun(state: AgentRunState, settled: SettledAgentRun): AgentRunState;
71
89
  export declare function initialAgentRunState(): AgentRunState;
72
90
  /** A parsed UI-message chunk — only the fields we read, all optional. */
73
91
  interface Chunk {
@@ -20,6 +20,24 @@
20
20
  */
21
21
  /** The tool the backend injects to carry a typed structured result. */
22
22
  const SUBMIT_TOOL = "submit_result";
23
+ /**
24
+ * Fold a POLLED settled run row into the stream-accumulated state — the shared
25
+ * adoption step for BOTH recovery paths (a dropped stream, and a stream that
26
+ * ended cleanly WITHOUT a `finish` frame — an edge can close a long SSE
27
+ * gracefully mid-run, which looks like completion but is a truncation).
28
+ *
29
+ * The row is the source of truth for status and the STRUCTURED output. A
30
+ * free-text run's row `output` is its prose string — that never enters
31
+ * `state.output` (a structured consumer reads `output.<field>`; a stray string
32
+ * would crash it). The free-text answer stays in the transcript text.
33
+ */
34
+ export function adoptSettledRun(state, settled) {
35
+ if (settled.status === "completed") {
36
+ const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
37
+ return { ...state, status: "completed", output: structured ?? state.output };
38
+ }
39
+ return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
40
+ }
23
41
  export function initialAgentRunState() {
24
42
  return { status: "streaming", items: [] };
25
43
  }
@@ -449,6 +449,13 @@ export interface AgentRunOptions {
449
449
  /** Groups this run with prior runs in the same working session; the agent
450
450
  * re-reads them for context. Mint a new id to "clear context". */
451
451
  sessionId: string;
452
+ /** Deliberately abort the run in flight and start this one in its place.
453
+ * Without it, `run()` is SINGLE-FLIGHT: a call while a run is streaming
454
+ * returns the in-flight run's promise instead of starting (and billing) a
455
+ * second run — so an accidental double-press resolves with the first run's
456
+ * result. The aborted-and-replaced run still executes and bills server-side;
457
+ * replacement is a deliberate act, never a side effect of an extra click. */
458
+ replace?: boolean;
452
459
  }
453
460
  /** The live state + controls returned by `useAgentRun`. */
454
461
  export interface UseAgentRun<TInput, TOutput> {
package/dist/src/hooks.js CHANGED
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import useSWR from "swr";
20
20
  import useSWRInfinite from "swr/infinite";
21
21
  import { rpc, rpcAgentRun, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
22
- import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent_stream.js";
22
+ import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, } from "./agent_stream.js";
23
23
  import { getMockRows, hasMockFlag } from "./mock.js";
24
24
  import { captureAppEvent } from "./analytics.js";
25
25
  export function useWorkflow(alias) {
@@ -440,6 +440,7 @@ export function useAgentRun(alias) {
440
440
  // header). Lets the hook poll the run to completion if the stream connection
441
441
  // drops, and cancel it server-side on an explicit stop.
442
442
  const runIdRef = useRef(null);
443
+ const inflightRef = useRef(null);
443
444
  // Guards setState after unmount and aborts any in-flight run on unmount, so a
444
445
  // stream never keeps writing to a dead component (or leaks the transport).
445
446
  const mountedRef = useRef(true);
@@ -455,6 +456,15 @@ export function useAgentRun(alias) {
455
456
  setState(s);
456
457
  }, []);
457
458
  const run = useCallback((input, opts) => {
459
+ // Single-flight: an extra press must not become a second paid run — the
460
+ // old abort-and-restart default kept the first run executing (and
461
+ // billing) server-side while the client went blind to it. Joining the
462
+ // in-flight promise makes a double-click resolve with the first run's
463
+ // result; `replace: true` is the explicit opt-in to abort-and-restart.
464
+ if (inflightRef.current && !opts.replace) {
465
+ captureAppEvent("app_agent_run_deduped", { alias });
466
+ return inflightRef.current;
467
+ }
458
468
  handleRef.current?.abort();
459
469
  runIdRef.current = null;
460
470
  let acc = initialAgentRunState();
@@ -486,17 +496,34 @@ export function useAgentRun(alias) {
486
496
  safeSetState(null);
487
497
  },
488
498
  };
489
- return handle.done
490
- .then(() => {
499
+ const inflight = handle.done
500
+ .then(async () => {
491
501
  if (aborted)
492
502
  return undefined;
493
- // `finish` normally settles status; guard a stream that ended without one.
494
- // Keep `output` as whatever `submit_result` set (else undefined) never
495
- // coerce the free-text into it (that string would crash a structured
496
- // consumer reading `output.<field>`; the answer stays in `text`).
503
+ // A clean stream end WITHOUT a `finish` frame is a truncation, not a
504
+ // completion an edge can close a long SSE gracefully mid-run (seen
505
+ // in production on ~2-minute runs), swallowing the frames that carry
506
+ // the structured result. The run is decoupled and settles server-side
507
+ // regardless, so the row is the source of truth: poll it, exactly
508
+ // like the dropped-with-error path below. (2026-07-18: four NOXH
509
+ // extractions completed server-side while every client showed
510
+ // failure through this hole.)
497
511
  if (acc.status === "streaming") {
498
- acc = { ...acc, status: "completed" };
512
+ const runId = runIdRef.current;
513
+ const settled = runId
514
+ ? await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current)
515
+ : null;
516
+ if (aborted)
517
+ return undefined;
518
+ acc = settled ? adoptSettledRun(acc, settled) : { ...acc, status: "completed" };
499
519
  safeSetState(acc);
520
+ // Fleet visibility: how often edges cut agent SSE streams. One
521
+ // event per truncation, with whether the row rescued the result.
522
+ captureAppEvent("app_agent_stream_truncated", {
523
+ recovered: settled != null,
524
+ settled_status: settled?.status ?? null,
525
+ has_output: acc.output !== undefined,
526
+ });
500
527
  }
501
528
  return acc.output;
502
529
  })
@@ -510,10 +537,7 @@ export function useAgentRun(alias) {
510
537
  if (runId) {
511
538
  const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
512
539
  if (settled && !aborted) {
513
- acc =
514
- settled.status === "completed"
515
- ? { ...acc, status: "completed", output: settled.output ?? acc.output }
516
- : { ...acc, status: "error", error: settled.error_message ?? "The run was stopped." };
540
+ acc = adoptSettledRun(acc, settled);
517
541
  safeSetState(acc);
518
542
  return acc.output;
519
543
  }
@@ -524,6 +548,12 @@ export function useAgentRun(alias) {
524
548
  safeSetState(acc);
525
549
  throw err;
526
550
  });
551
+ const tracked = inflight.finally(() => {
552
+ if (inflightRef.current === tracked)
553
+ inflightRef.current = null;
554
+ });
555
+ inflightRef.current = tracked;
556
+ return tracked;
527
557
  }, [alias, safeSetState]);
528
558
  const abort = useCallback(() => handleRef.current?.abort(), []);
529
559
  const cancel = useCallback(() => {
package/docs/ai.md CHANGED
@@ -49,7 +49,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
49
49
 
50
50
  | Member | Type | What it is |
51
51
  |---|---|---|
52
- | `run` | `(input, { sessionId }) => 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). Calling it again **aborts any run still in flight** |
52
+ | `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 |
53
53
  | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
54
54
  | `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) |
55
55
  | `status` | `"idle" \| "streaming" \| "completed" \| "error"` | Whole-run state. `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
@@ -121,9 +121,14 @@ Both settle the in-flight `run()` promise cleanly with `undefined` — a stop is
121
121
 
122
122
  ### Runs survive dropped connections
123
123
 
124
- The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start; if the connection then fails, it **polls the persisted run to completion** (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error so a run that settles within that 11-minute poll window survives a flaky connection. A run still going when the poll deadline passes (the hard cap is 20 min) is **not** recovered on the client: the poll gives up and the drop surfaces as an error, though the result is still persisted server-side. Only when no run id was ever received (the run never started) does the failure reject before any polling.
124
+ The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start and recovers through the **persisted run row** the source of truthin BOTH failure shapes:
125
125
 
126
- **Warning:** on this recovery path the hook adopts the *persisted* run's output and a **free-text** run is persisted with its final answer text as the output. So after a connection-drop recovery, a free-text agent's `output` can carry the answer **string** (the one place the "never a stray string" rule doesn't hold), while `text` keeps only what streamed before the drop. If you consume free-text agents, guard with `typeof output === "string"` on the resolved value.
126
+ - **The connection drops with an error** → the hook polls the persisted run to completion (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error.
127
+ - **The stream ends cleanly WITHOUT a `finish` frame** — an edge/proxy can close a long SSE gracefully mid-run, which looks like completion but swallowed the trailing frames (including the structured result). The hook detects the missing `finish` and polls the row the same way. Each such truncation emits the `app_agent_stream_truncated` analytics event (with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
128
+
129
+ A run still going when the poll deadline passes (the hard cap is 20 min) is **not** recovered on the client: the poll gives up and the drop surfaces as an error, though the result is still persisted server-side. Only when no run id was ever received (the run never started) does the failure reject before any polling.
130
+
131
+ On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; read the full settled answer via `useAgentRuns(sessionId)` if you need it.
127
132
 
128
133
  ### Sessions
129
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.52.0",
3
+ "version": "0.52.2",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {