@lotics/app-sdk 0.52.0 → 0.52.1

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
  }
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) {
@@ -487,16 +487,33 @@ export function useAgentRun(alias) {
487
487
  },
488
488
  };
489
489
  return handle.done
490
- .then(() => {
490
+ .then(async () => {
491
491
  if (aborted)
492
492
  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`).
493
+ // A clean stream end WITHOUT a `finish` frame is a truncation, not a
494
+ // completion an edge can close a long SSE gracefully mid-run (seen
495
+ // in production on ~2-minute runs), swallowing the frames that carry
496
+ // the structured result. The run is decoupled and settles server-side
497
+ // regardless, so the row is the source of truth: poll it, exactly
498
+ // like the dropped-with-error path below. (2026-07-18: four NOXH
499
+ // extractions completed server-side while every client showed
500
+ // failure through this hole.)
497
501
  if (acc.status === "streaming") {
498
- acc = { ...acc, status: "completed" };
502
+ const runId = runIdRef.current;
503
+ const settled = runId
504
+ ? await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current)
505
+ : null;
506
+ if (aborted)
507
+ return undefined;
508
+ acc = settled ? adoptSettledRun(acc, settled) : { ...acc, status: "completed" };
499
509
  safeSetState(acc);
510
+ // Fleet visibility: how often edges cut agent SSE streams. One
511
+ // event per truncation, with whether the row rescued the result.
512
+ captureAppEvent("app_agent_stream_truncated", {
513
+ recovered: settled != null,
514
+ settled_status: settled?.status ?? null,
515
+ has_output: acc.output !== undefined,
516
+ });
500
517
  }
501
518
  return acc.output;
502
519
  })
@@ -510,10 +527,7 @@ export function useAgentRun(alias) {
510
527
  if (runId) {
511
528
  const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
512
529
  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." };
530
+ acc = adoptSettledRun(acc, settled);
517
531
  safeSetState(acc);
518
532
  return acc.output;
519
533
  }
package/docs/ai.md CHANGED
@@ -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.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {