@lotics/app-sdk 0.55.1 → 0.55.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.
@@ -45,6 +45,14 @@ export interface SettledAgentRun {
45
45
  status: string;
46
46
  output?: unknown;
47
47
  error_message?: string | null;
48
+ /** Present on a polled `awaiting_input` row: the pending ask derived from the
49
+ * server-side transcript, so a client that lost the stream (or reloaded) can
50
+ * rebuild the question without ever having received the part. */
51
+ pending_interactive?: {
52
+ tool_call_id: string;
53
+ tool_name: string;
54
+ input: unknown;
55
+ } | null;
48
56
  }
49
57
  /**
50
58
  * Fold a POLLED settled run row into the stream-accumulated state — the shared
@@ -41,10 +41,26 @@ export function adoptSettledRun(state, settled) {
41
41
  const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
42
42
  return { ...state, status: "completed", output: structured ?? state.output };
43
43
  }
44
- // A PARKED row is a live, resumable state — the pending ask is already in the
45
- // transcript parts; never coerce it into an error.
44
+ // A PARKED row is a live, resumable state — never coerce it into an error.
45
+ // The invariant the hook layer relies on: `awaiting_input` always yields an
46
+ // answerable pending part. When the stream died before the ask part arrived,
47
+ // rebuild it from the row's derived `pending_interactive`.
46
48
  if (settled.status === "awaiting_input") {
47
- return { ...state, status: "awaiting_input" };
49
+ const next = { ...state, status: "awaiting_input" };
50
+ if (pendingInteractiveCall(next))
51
+ return next;
52
+ const p = settled.pending_interactive;
53
+ const alreadyPresent = p && state.parts.some((part) => part.type === "dynamic-tool" && part.toolCallId === p.tool_call_id);
54
+ if (p && INTERACTIVE_TOOLS.has(p.tool_name) && !alreadyPresent) {
55
+ return {
56
+ ...next,
57
+ parts: [
58
+ ...state.parts,
59
+ { type: "dynamic-tool", toolName: p.tool_name, toolCallId: p.tool_call_id, state: "input-available", input: p.input },
60
+ ],
61
+ };
62
+ }
63
+ return next;
48
64
  }
49
65
  return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
50
66
  }
@@ -103,7 +119,7 @@ export function buildChoiceOutput(questions, answers) {
103
119
  if (!answer || value.length === 0) {
104
120
  return { type: "skipped", question_index: index, question_number: questionNumber, question_text: question.question };
105
121
  }
106
- const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label === answer.value);
122
+ const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label === value);
107
123
  if (optionIndex >= 0) {
108
124
  const option = question.options[optionIndex];
109
125
  return {
@@ -531,6 +531,14 @@ export interface AgentRunRecord {
531
531
  error_message: string | null;
532
532
  started_at: string;
533
533
  completed_at: string | null;
534
+ /** Single-run GET only, while `status` is "awaiting_input": the pending ask
535
+ * derived server-side from the transcript — lets a reconnecting client
536
+ * rebuild the question without the live stream. */
537
+ pending_interactive?: {
538
+ tool_call_id: string;
539
+ tool_name: string;
540
+ input: unknown;
541
+ } | null;
534
542
  }
535
543
  interface AgentRunsState {
536
544
  runs: AgentRunRecord[];
package/dist/src/hooks.js CHANGED
@@ -467,12 +467,36 @@ export function useAgentRun(alias) {
467
467
  * connection, so a truncation polls the row instead of surfacing a network
468
468
  * error. A leg that ends `awaiting_input` resolves with `undefined`; the
469
469
  * final leg resolves with the structured output.
470
+ *
471
+ * `revertTo` (the continuation leg): the leg's initial state is an OPTIMISTIC
472
+ * commit (the ask settled locally before the server accepted the answer). A
473
+ * request that fails before ANY chunk arrives means the server rejected it and
474
+ * the row never left `awaiting_input` — restore the pre-answer state so the
475
+ * question is answerable again, and REJECT so the app surfaces the error.
476
+ * Without this, a 400/409 would be misread as a mid-run connection drop,
477
+ * poll-adopted, and silently swallowed.
470
478
  */
471
- const streamLeg = useCallback((start, initial) => {
479
+ const streamLeg = useCallback((start, initial, revertTo) => {
472
480
  let acc = initial;
473
481
  let buffer = "";
474
482
  let aborted = false;
483
+ let received = false;
475
484
  safeSetState(acc);
485
+ // Enforce the invariant the wizard relies on: `awaiting_input` always
486
+ // yields an answerable pendingChoice. A polled parked row whose question
487
+ // cannot be rebuilt (no part in the stream, none derivable from the row)
488
+ // must fail loud and retryable, never sit as a silent dead end.
489
+ const adoptGuarded = (state, settled) => {
490
+ const adopted = adoptSettledRun(state, settled);
491
+ if (adopted.status === "awaiting_input" && !pendingInteractiveCall(adopted)) {
492
+ return {
493
+ ...adopted,
494
+ status: "error",
495
+ error: "The run is waiting for an answer that could not be recovered. Run it again.",
496
+ };
497
+ }
498
+ return adopted;
499
+ };
476
500
  const handle = start((textChunk) => {
477
501
  if (aborted)
478
502
  return;
@@ -481,6 +505,7 @@ export function useAgentRun(alias) {
481
505
  buffer = rest;
482
506
  if (chunks.length === 0)
483
507
  return;
508
+ received = true;
484
509
  for (const c of chunks)
485
510
  acc = reduceAgentChunk(acc, c);
486
511
  safeSetState({ ...acc });
@@ -521,7 +546,7 @@ export function useAgentRun(alias) {
521
546
  // a fake "completed": a structured consumer reading a completed
522
547
  // state with no output would render success around a missing result.
523
548
  acc = settled
524
- ? adoptSettledRun(acc, settled)
549
+ ? adoptGuarded(acc, settled)
525
550
  : {
526
551
  ...acc,
527
552
  status: "error",
@@ -542,6 +567,14 @@ export function useAgentRun(alias) {
542
567
  .catch(async (err) => {
543
568
  if (aborted)
544
569
  return undefined;
570
+ // The continue request itself was rejected — the server never resumed
571
+ // the run (400 invalid answer, 409 raced cancel/expiry, network at
572
+ // connect). Restore the pre-answer parked state and surface the error;
573
+ // this is a request failure, not a stream truncation.
574
+ if (revertTo && !received) {
575
+ safeSetState(revertTo);
576
+ throw err;
577
+ }
545
578
  // The stream connection dropped, but the run is decoupled from it and
546
579
  // keeps executing server-side. Poll the persisted run to completion and
547
580
  // surface its result instead of a network error — work is never lost.
@@ -551,7 +584,7 @@ export function useAgentRun(alias) {
551
584
  if (aborted)
552
585
  return undefined;
553
586
  if (settled) {
554
- acc = adoptSettledRun(acc, settled);
587
+ acc = adoptGuarded(acc, settled);
555
588
  safeSetState(acc);
556
589
  }
557
590
  // The same fleet-visibility beacon as the clean-end path — a dropped
@@ -593,23 +626,29 @@ export function useAgentRun(alias) {
593
626
  });
594
627
  inflightRef.current = tracked;
595
628
  return tracked;
596
- }, [alias, safeSetState, streamLeg]);
629
+ }, [alias, streamLeg]);
597
630
  // The run's pending ask — non-null exactly while it is parked on a question.
598
631
  const pendingChoice = state ? pendingInteractiveCall(state) : null;
599
632
  const answerChoice = useCallback((answers) => {
633
+ // Join first: a double-submit (the wizard's button pressed twice) must
634
+ // land on the in-flight continuation, not reject on the already-settled
635
+ // pending check below.
636
+ if (inflightRef.current)
637
+ return inflightRef.current;
600
638
  const current = stateRef.current;
601
639
  const runId = runIdRef.current;
602
640
  const pending = current ? pendingInteractiveCall(current) : null;
603
641
  if (!current || !runId || !pending) {
604
642
  return Promise.reject(new Error("No pending question to answer."));
605
643
  }
606
- if (inflightRef.current)
607
- return inflightRef.current;
608
644
  const output = buildChoiceOutput(pending.questions, answers);
609
- const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output: output }, onText),
645
+ const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output }, onText),
610
646
  // The answered part settles locally (its output rides the feed's
611
647
  // on-demand reveal) and the state re-enters streaming for the leg.
612
- applyInteractiveAnswer(current, pending.toolCallId, output));
648
+ applyInteractiveAnswer(current, pending.toolCallId, output),
649
+ // On a rejected request the leg restores this pre-answer state, so the
650
+ // wizard reappears and the rejection reaches the app.
651
+ current);
613
652
  const tracked = inflight.finally(() => {
614
653
  if (inflightRef.current === tracked)
615
654
  inflightRef.current = null;
package/dist/src/rpc.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AskUserChoiceOutput } from "./agent_stream.js";
1
2
  import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
2
3
  /**
3
4
  * RPC bridge for a custom-code app's data operations.
@@ -151,7 +152,7 @@ export interface AgentRunContinuePayload {
151
152
  run_id: string;
152
153
  tool_call_id: string;
153
154
  /** The `ask_user_choice` output the user assembled (validated server-side). */
154
- output: Record<string, unknown>;
155
+ output: AskUserChoiceOutput;
155
156
  }
156
157
  /**
157
158
  * Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
package/docs/ai.md CHANGED
@@ -54,7 +54,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
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" \| "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) |
56
56
  | `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}`) |
57
- | `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 |
57
+ | `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 |
58
58
  | `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` |
59
59
  | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
60
60
  | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
@@ -76,8 +76,8 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
76
76
  <AgentRun
77
77
  parts={run.parts}
78
78
  state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
79
- error={run.error} // the breaking error — a terminal danger row
80
- labelForTool={(name) => toolLabels[name]} // optional: localize tool labels
79
+ error={run.error} // the breaking error — a terminal danger row
80
+ labelForCall={(call) => toolLabels[call.toolName]} // optional: localize tool labels
81
81
  />
82
82
  ```
83
83
 
@@ -109,9 +109,16 @@ const run = useAgentRun("importer");
109
109
 
110
110
  The ask renders in the feed as a settled tool row once answered (the answer rides its
111
111
  on-demand reveal). `run()`'s promise resolves `undefined` when the run parks — the
112
- continuation's promise (from `answerChoice`) carries the final output. `cancel()` on a parked
113
- run settles it `aborted` immediately; an unanswered park expires after an hour (`error`,
114
- "timed out waiting for an answer"). Multi-ask runs work each answer may park again.
112
+ continuation's promise (from `answerChoice`) carries the final output. The question is as
113
+ connection-decoupled as the run: a dropped stream can't lose it the hook's recovery poll
114
+ rebuilds the pending question from the persisted run, so `awaiting_input` always yields an
115
+ answerable `pendingChoice` (the one unrecoverable corner surfaces a retryable `error`, never
116
+ a silent dead end). If the server refuses an answer (an invalid submission, a raced cancel
117
+ or expiry, a connection failure), `answerChoice` REJECTS and the pending question is
118
+ restored — surface the error and let the user submit again; nothing is half-committed.
119
+ `cancel()` on a parked run settles it `aborted` immediately; an unanswered park expires
120
+ after an hour (`error`, "timed out waiting for an answer"). Multi-ask runs work — each
121
+ answer may park again.
115
122
 
116
123
  ### `output` typing and the inner-field caveat
117
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.55.1",
3
+ "version": "0.55.3",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,19 +26,16 @@
26
26
  "prepublishOnly": "npm run build"
27
27
  },
28
28
  "dependencies": {
29
+ "ai": "^7.0.30",
29
30
  "posthog-js": "^1.352.0",
30
31
  "swr": "^2.4.1"
31
32
  },
32
33
  "peerDependencies": {
33
- "ai": ">=7.0.0",
34
34
  "react": "^19.2.0",
35
35
  "react-dom": "^19.2.0",
36
36
  "react-router-dom": "^7.0.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
- "ai": {
40
- "optional": true
41
- },
42
39
  "react-router-dom": {
43
40
  "optional": true
44
41
  }