@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.
- package/dist/src/agent_stream.d.ts +18 -0
- package/dist/src/agent_stream.js +18 -0
- package/dist/src/hooks.d.ts +7 -0
- package/dist/src/hooks.js +42 -12
- package/docs/ai.md +8 -3
- package/package.json +1 -1
|
@@ -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 {
|
package/dist/src/agent_stream.js
CHANGED
|
@@ -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.d.ts
CHANGED
|
@@ -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
|
-
|
|
490
|
-
.then(() => {
|
|
499
|
+
const inflight = handle.done
|
|
500
|
+
.then(async () => {
|
|
491
501
|
if (aborted)
|
|
492
502
|
return undefined;
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
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
|
-
|
|
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).
|
|
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
|
|
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 truth — in BOTH failure shapes:
|
|
125
125
|
|
|
126
|
-
**
|
|
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
|
|