@ai-matrx/chat 0.7.21 → 0.7.23

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.7.23
4
+ ### Unreleased
5
+
6
+ ### A run parked on the person waits for them (lane F15)
7
+
8
+ - **`runHeadlessAgentJson` / `adoptHeadlessAgentJson` / `continueAgentJson`: waiting on the person is a
9
+ waiting state, never a failure.** A turn that ends parked on a question (`ask_person`,
10
+ `suspended_awaiting_client`) no longer settles from the parked call (the Applet builder said "The
11
+ builder answered without an app" over a question it never showed). The wait fires the new
12
+ `onAwaitingPerson`, pauses `timeoutMs`, re-reads the conversation (`rereadAndFollow`) when the ask
13
+ closes anywhere, and resolves from the resumed turn's answer; `personWaitMs` (default 6h) bounds it.
14
+ A parked run that stops waiting harvests nothing (`awaitingPerson: true`). Consumer action: a surface
15
+ that runs an agent which may ask passes `onAwaitingPerson` to say it is waiting.
16
+ - **A dropped connection is not a failed run.** When the stream dies on a connection loss
17
+ (`transport_lost` / `heartbeat_timeout`) the server finishes the turn and `reconnectServerOperation`
18
+ re-reads it; the headless wait now waits for that (5 min ceiling) and resolves from the answer it
19
+ brings, instead of failing "before returning a result" while the build finished on the server. A
20
+ reconnect that brings nothing ends with the stream's own reason. Consumer action: none.
21
+ - **A settled turn that ended on a tool call shows the call, not "Processing…"** (`isWaitingForFirstContent`):
22
+ the parked ask's card now draws in place of a shimmer that never ended. Consumer action: none.
23
+
24
+ ## 0.7.22
25
+
26
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
27
+ `git diff npm/chat/v0.7.21..npm/chat/v0.7.22 -- apps/shared/chat`).
28
+ No source changes intended and no consumer action required.
29
+
3
30
  ## 0.7.21
4
31
  ### Unreleased
5
32
 
@@ -22,7 +22,7 @@ import type { RequestInitiation } from "../../../types/instance.types.js";
22
22
  import type { Resource } from "../../../resources/types.js";
23
23
  import { type HostTurnInput } from "./execute-instance.thunk.js";
24
24
  import { type AdoptedAgentJsonOptions, type HeadlessAgentJsonResult } from "./run-headless-agent-json.js";
25
- export interface ContinueAgentJsonOptions extends Pick<AdoptedAgentJsonOptions, "surfaceKey" | "timeoutMs" | "pollIntervalMs" | "settleMs" | "expect" | "onResult" | "signal" | "failureMessages"> {
25
+ export interface ContinueAgentJsonOptions extends Pick<AdoptedAgentJsonOptions, "surfaceKey" | "timeoutMs" | "pollIntervalMs" | "settleMs" | "expect" | "onResult" | "signal" | "failureMessages" | "onAwaitingPerson" | "personWaitMs"> {
26
26
  conversationId: string;
27
27
  /** What the person typed for this turn. Exclusive with `hostTurn`. */
28
28
  userInput?: string;
@@ -54,7 +54,9 @@ async function continueAgentJson(dispatch, getState, opts) {
54
54
  ...opts.expect ? { expect: opts.expect } : {},
55
55
  ...opts.onResult ? { onResult: opts.onResult } : {},
56
56
  ...opts.signal ? { signal: opts.signal } : {},
57
- ...opts.failureMessages ? { failureMessages: opts.failureMessages } : {}
57
+ ...opts.failureMessages ? { failureMessages: opts.failureMessages } : {},
58
+ ...opts.onAwaitingPerson ? { onAwaitingPerson: opts.onAwaitingPerson } : {},
59
+ ...opts.personWaitMs !== void 0 ? { personWaitMs: opts.personWaitMs } : {}
58
60
  });
59
61
  } catch (err) {
60
62
  const message = err instanceof Error ? err.message : String(err);
@@ -168,6 +168,19 @@ export interface HeadlessAgentJsonOptions {
168
168
  * discards. Pair it with `onResult` so an unmounting surface still persists.
169
169
  */
170
170
  signal?: AbortSignal;
171
+ /**
172
+ * THE RUN IS WAITING ON THE PERSON, NOT FAILED. The turn ended parked on a question
173
+ * (`ask_person`, or a server park on a person): its answer is the turn that resumes once
174
+ * they answer. The wait pauses `timeoutMs`, fires this once so the surface can say so and
175
+ * show the question, re-reads the conversation when the ask closes (answered here, in
176
+ * another tab or by text), and resolves from the resumed turn. Bounded by `personWaitMs`.
177
+ */
178
+ onAwaitingPerson?: (info: {
179
+ requestId: string;
180
+ conversationId: string;
181
+ }) => void;
182
+ /** Ceiling on waiting for the person once a run parks on them. Default 6h; `signal` stops it sooner. */
183
+ personWaitMs?: number;
171
184
  /** Fires with the conversation id BEFORE the stream runs (live UI handle). */
172
185
  onConversationCreated?: (conversationId: string) => void;
173
186
  /**
@@ -271,6 +284,8 @@ export interface HeadlessAgentJsonResult {
271
284
  * from a failure that carries its own reason.
272
285
  */
273
286
  noResult?: boolean;
287
+ /** `true` when the run ended parked on the person and stopped waiting before they answered. */
288
+ awaitingPerson?: boolean;
274
289
  }
275
290
  /**
276
291
  * 🚨 A WARNED CHOICE THAT ANSWERS WITHOUT A REQUIRED KEY FAILS PLAINLY.
@@ -333,6 +348,10 @@ export interface AdoptedAgentJsonOptions {
333
348
  onResult?: HeadlessAgentJsonOptions["onResult"];
334
349
  /** Stop waiting and harvest — same contract as `HeadlessAgentJsonOptions.signal`. */
335
350
  signal?: AbortSignal;
351
+ /** Same contract as `HeadlessAgentJsonOptions.onAwaitingPerson`. */
352
+ onAwaitingPerson?: HeadlessAgentJsonOptions["onAwaitingPerson"];
353
+ /** Same contract as `HeadlessAgentJsonOptions.personWaitMs`. */
354
+ personWaitMs?: number;
336
355
  /** Per-feature user-facing failure copy. */
337
356
  failureMessages?: HeadlessAgentJsonOptions["failureMessages"];
338
357
  /** See `deferNoJsonCapture` on `HeadlessAgentJsonOptions`. */
@@ -8,10 +8,17 @@ import {
8
8
  selectExtractedJson,
9
9
  selectFirstExtractedObject,
10
10
  selectJsonExtractionComplete,
11
+ selectRequestAwaitingPerson,
11
12
  selectRequestError,
12
13
  selectRequestStatus,
13
14
  selectRenderBlocksByType
14
15
  } from "../active-requests/active-requests.selectors.js";
16
+ import {
17
+ selectLatestAnswerText as selectCommittedAnswerText,
18
+ selectLatestAssistantMessageId
19
+ } from "../messages/messages.selectors.js";
20
+ import { countOpenAsksForConversation } from "../../../runtime-reconnect/parked-on-person.js";
21
+ import { rereadAndFollow } from "../../../runtime-reconnect/reread-and-follow.js";
15
22
  import {
16
23
  selectLatestAnswerText,
17
24
  selectLatestRequestId
@@ -44,6 +51,10 @@ function safeShape(err) {
44
51
  const DEFAULT_TIMEOUT_MS = 12e4;
45
52
  const DEFAULT_POLL_MS = 250;
46
53
  const DEFAULT_SETTLE_MS = 6e3;
54
+ const DEFAULT_PERSON_WAIT_MS = 6 * 60 * 6e4;
55
+ const RECONNECT_WAIT_MS = 5 * 6e4;
56
+ const isConnectionLoss = (errorType) => errorType === "transport_lost" || errorType === "heartbeat_timeout";
57
+ const PERSON_ASK_POLL_MS = 1e4;
47
58
  const DEFAULT_MESSAGES = {
48
59
  streamError: "The agent failed before returning a result.",
49
60
  noJson: "The agent finished but produced no structured JSON.",
@@ -243,6 +254,8 @@ async function adoptHeadlessAgentJson(dispatch, getState, opts) {
243
254
  msgs,
244
255
  expect: opts.expect ?? "json",
245
256
  ...opts.signal ? { signal: opts.signal } : {},
257
+ ...opts.onAwaitingPerson ? { onAwaitingPerson: opts.onAwaitingPerson } : {},
258
+ personWaitMs: opts.personWaitMs ?? DEFAULT_PERSON_WAIT_MS,
246
259
  deferNoJsonCapture: opts.deferNoJsonCapture === true
247
260
  });
248
261
  } catch (err) {
@@ -388,6 +401,8 @@ async function launchAndWait(dispatch, getState, opts) {
388
401
  msgs,
389
402
  expect: opts.expect ?? "json",
390
403
  ...opts.signal ? { signal: opts.signal } : {},
404
+ ...opts.onAwaitingPerson ? { onAwaitingPerson: opts.onAwaitingPerson } : {},
405
+ personWaitMs: opts.personWaitMs ?? DEFAULT_PERSON_WAIT_MS,
391
406
  deferNoJsonCapture: opts.deferNoJsonCapture === true
392
407
  });
393
408
  } catch (err) {
@@ -549,13 +564,45 @@ async function waitForExtraction(dispatch, getState, args) {
549
564
  ...base()
550
565
  };
551
566
  };
567
+ const answerSince = (state) => {
568
+ if (!state.messages) return `live\0${selectLatestAnswerText(conversationId)(state)}`;
569
+ const committed = selectCommittedAnswerText(conversationId)(state);
570
+ if (committed.trim()) return `${selectLatestAssistantMessageId(conversationId)(state) ?? ""}\0${committed}`;
571
+ return `live\0${selectLatestAnswerText(conversationId)(state)}`;
572
+ };
573
+ const unanswered = (why) => ({
574
+ success: false,
575
+ data: null,
576
+ error: why === "timeout" ? "Your agent's question was not answered in time." : "Stopped waiting for your answer.",
577
+ awaitingPerson: true,
578
+ ...base()
579
+ });
580
+ const startMarker = answerSince(getState());
581
+ let lostAt = null;
582
+ const reconnectedAnswer = () => {
583
+ const marker = answerSince(getState());
584
+ if (marker === startMarker) return null;
585
+ return fuzzyFallback(marker.slice(marker.indexOf("\0") + 1));
586
+ };
587
+ let person = null;
552
588
  try {
553
- while (Date.now() - start < args.timeoutMs) {
589
+ while (person ? Date.now() - person.since < args.personWaitMs : Date.now() - start < args.timeoutMs || lostAt !== null && Date.now() - lostAt < RECONNECT_WAIT_MS) {
554
590
  if (args.signal?.aborted) {
555
- return settle("aborted", noResultMsg);
591
+ return person ? unanswered("aborted") : settle("aborted", noResultMsg);
556
592
  }
557
593
  const state = getState();
558
594
  const status = selectRequestStatus(requestId)(state);
595
+ if (lostAt !== null) {
596
+ const recovered = reconnectedAnswer();
597
+ if (recovered != null) return { success: true, data: recovered, ...base() };
598
+ }
599
+ if (status === "error" && isConnectionLoss(selectRequestError(requestId)(state)?.error_type)) {
600
+ lostAt ??= Date.now();
601
+ if (Date.now() - lostAt < RECONNECT_WAIT_MS) {
602
+ await sleep(args.pollMs);
603
+ continue;
604
+ }
605
+ }
559
606
  if (status === "error") {
560
607
  const reqError = selectRequestError(requestId)(state);
561
608
  const { data } = resolveRunData(getState, requestId, conversationId);
@@ -572,6 +619,44 @@ async function waitForExtraction(dispatch, getState, args) {
572
619
  ...base()
573
620
  };
574
621
  }
622
+ if (!person && status !== void 0 && TERMINAL_STATUSES.has(status) && selectRequestAwaitingPerson(requestId)(state)) {
623
+ person = {
624
+ since: Date.now(),
625
+ baseline: answerSince(state),
626
+ askedAt: Date.now(),
627
+ following: false,
628
+ changedAt: null
629
+ };
630
+ try {
631
+ args.onAwaitingPerson?.({ requestId, conversationId });
632
+ } catch (err) {
633
+ console.error("[runHeadlessAgentJson] onAwaitingPerson threw", err);
634
+ }
635
+ }
636
+ if (person) {
637
+ const now = Date.now();
638
+ if (!person.following && now - person.askedAt >= PERSON_ASK_POLL_MS) {
639
+ person.askedAt = now;
640
+ try {
641
+ if (await countOpenAsksForConversation(conversationId) === 0) {
642
+ person.following = true;
643
+ await rereadAndFollow(dispatch, conversationId, "runHeadlessAgentJson: the ask closed");
644
+ }
645
+ } catch {
646
+ }
647
+ }
648
+ const now2 = getState();
649
+ const marker = answerSince(now2);
650
+ const answer = marker.slice(marker.indexOf("\0") + 1);
651
+ if (answer.trim() && marker !== person.baseline) {
652
+ const data = fuzzyFallback(answer);
653
+ if (data != null) return { success: true, data, ...base() };
654
+ person.changedAt ??= now;
655
+ if (now - person.changedAt > args.settleMs) return settle("stream-ended", noResultMsg);
656
+ }
657
+ await sleep(args.pollMs);
658
+ continue;
659
+ }
575
660
  if (selectJsonExtractionComplete(requestId)(state)) {
576
661
  return settle("extraction-complete", noResultMsg);
577
662
  }
@@ -586,6 +671,18 @@ async function waitForExtraction(dispatch, getState, args) {
586
671
  }
587
672
  await sleep(args.pollMs);
588
673
  }
674
+ if (person) return unanswered("timeout");
675
+ if (lostAt !== null && selectRequestStatus(requestId)(getState()) === "error") {
676
+ const reqError = selectRequestError(requestId)(getState());
677
+ const { data } = resolveRunData(getState, requestId, conversationId);
678
+ return {
679
+ success: false,
680
+ data,
681
+ error: reqError?.user_message ?? reqError?.message ?? msgs.streamError,
682
+ ...reqError?.error_type ? { errorDetail: reqError.error_type } : {},
683
+ ...base()
684
+ };
685
+ }
589
686
  return settle("timeout", msgs.timeout);
590
687
  } finally {
591
688
  dispatch(releaseRequestForViewer({ requestId, viewerId }));
@@ -5,6 +5,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef } fr
5
5
  import { cn } from "@ai-matrx/design-system";
6
6
  import { WIDE_FIGURE_HOST_CLASS, WIDE_FIGURE_PROSE_CLIP_CLASS } from "../../agents/components/shared/assistant-message-layout.js";
7
7
  import { splitContentIntoBlocksV2 } from "@ai-matrx/rich-content/display/markdown-classification/processors/utils/content-splitter-v2";
8
+ import { isWaitingForFirstContent } from "./waiting-for-content.js";
8
9
  import { renderSettledFromRecord, settledOneShotBlocks } from "@ai-matrx/rich-content/display/chat-markdown/settle-stream-blocks";
9
10
  import { expandTextBlocksInList } from "@ai-matrx/rich-content/display/markdown-classification/processors/utils/expand-text-blocks";
10
11
  import { reuseUnchangedBlocks } from "@ai-matrx/rich-content/display/chat-markdown/stable-blocks";
@@ -316,13 +317,19 @@ const EnhancedChatMarkdownInternal = ({
316
317
  const currentContent = editedContent ?? resolvedContent;
317
318
  const hasRequestOrTaskId = requestId || taskId;
318
319
  const hasReceivedNonTextContent = serverProcessedBlocks && serverProcessedBlocks.length > 0 || hasReduxRenderBlocks;
319
- const isWaitingForContent = hasRequestOrTaskId && !resolvedContent.trim() && !hasReceivedNonTextContent;
320
- const hasUnifiedSpecial = !settledFromRecord && (streamSlotStart !== void 0 || streamSlotEnd !== void 0 || agentCallId !== void 0 || unifiedSlots.some(
321
- (s) => s.kind === "tool" || s.kind === "status" || s.kind === "error" || s.kind === "thinking" || s.kind === "render_block" && blockCarriesDataNotText(renderBlocksMap[s.blockId])
322
- ));
323
320
  const hasDbInterleavedSpecial = messageInterleavedContent.some(
324
321
  (s) => s.type === "db_tool" || s.type === "thinking"
325
322
  );
323
+ const isWaitingForContent = isWaitingForFirstContent({
324
+ hasRequestOrTaskId: !!hasRequestOrTaskId,
325
+ resolvedContent,
326
+ hasReceivedNonTextContent: !!hasReceivedNonTextContent,
327
+ settledFromRecord,
328
+ recordHasNonText: hasDbInterleavedSpecial
329
+ });
330
+ const hasUnifiedSpecial = !settledFromRecord && (streamSlotStart !== void 0 || streamSlotEnd !== void 0 || agentCallId !== void 0 || unifiedSlots.some(
331
+ (s) => s.kind === "tool" || s.kind === "status" || s.kind === "error" || s.kind === "thinking" || s.kind === "render_block" && blockCarriesDataNotText(renderBlocksMap[s.blockId])
332
+ ));
326
333
  const toolLifecycleMapSelector = useMemo(
327
334
  () => requestId ? selectToolLifecycleMap(requestId) : () => void 0,
328
335
  [requestId]
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Is this assistant turn still waiting for its first content (the "Processing…" shimmer)?
3
+ *
4
+ * A turn that ENDED on a tool call with no text — an `ask_person` parked on the person — is
5
+ * settled: its record holds the call, and the call is what the person must see. Before this rule
6
+ * the empty text kept the shimmer up forever over the question it was hiding (the Applet
7
+ * builder, live 2026-10-09: "Processing…" while the server waited on a choose_one ask).
8
+ */
9
+ export declare function isWaitingForFirstContent(args: {
10
+ hasRequestOrTaskId: boolean;
11
+ resolvedContent: string;
12
+ hasReceivedNonTextContent: boolean;
13
+ settledFromRecord: boolean;
14
+ recordHasNonText: boolean;
15
+ }): boolean;
@@ -0,0 +1,10 @@
1
+ function isWaitingForFirstContent(args) {
2
+ if (!args.hasRequestOrTaskId) return false;
3
+ if (args.resolvedContent.trim()) return false;
4
+ if (args.hasReceivedNonTextContent) return false;
5
+ if (args.settledFromRecord && args.recordHasNonText) return false;
6
+ return true;
7
+ }
8
+ export {
9
+ isWaitingForFirstContent
10
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
4
4
  "description": "THE one AI Matrx person-to-agent chat system: the full chat (running agents, conversations, tool calls, streaming, context, surfaces, voice) as explicit per-module subpaths @ai-matrx/chat/<domain>/<module> (no wildcards: scripts/public-modules.json) over a host contract (/host, /host/react) whose only required value is a db. One chat, one renderer: the old kernel entries (., /external-store, /matrx, /react, /tools, styles.css, tokens.css) are retired.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4681,12 +4681,13 @@
4681
4681
  "server-only": "^0.0.1",
4682
4682
  "uuid": "^14.0.2",
4683
4683
  "zod": "^4.6.5",
4684
+ "@ai-matrx/associations": "latest",
4684
4685
  "@ai-matrx/browser-audio": "latest",
4686
+ "@ai-matrx/alchemy": "latest",
4685
4687
  "@ai-matrx/canvas": "latest",
4686
- "@ai-matrx/associations": "latest",
4687
4688
  "@ai-matrx/content-ir": "latest",
4688
- "@ai-matrx/data": "latest",
4689
4689
  "@ai-matrx/content-ir-react": "latest",
4690
+ "@ai-matrx/data": "latest",
4690
4691
  "@ai-matrx/design-system": "latest",
4691
4692
  "@ai-matrx/diff": "latest",
4692
4693
  "@ai-matrx/icons": "latest",
@@ -4698,8 +4699,7 @@
4698
4699
  "@ai-matrx/records-ui": "latest",
4699
4700
  "@ai-matrx/rich-content": "latest",
4700
4701
  "@ai-matrx/rich-editor": "latest",
4701
- "@ai-matrx/agents": "latest",
4702
- "@ai-matrx/alchemy": "latest"
4702
+ "@ai-matrx/agents": "latest"
4703
4703
  },
4704
4704
  "devDependencies": {
4705
4705
  "@arethetypeswrong/cli": "^0.18.5",
@@ -4818,5 +4818,5 @@
4818
4818
  "check:side-effects": "node ../scripts/check-side-effects-listed.mjs . && node ../scripts/check-side-effects-listed.mjs --self-test",
4819
4819
  "check:public-surface": "node ./scripts/sync-public-surface.mjs --check && node ./scripts/sync-public-surface.mjs --self-test"
4820
4820
  },
4821
- "gitHead": "60c95feed80fd7dcac973ff29aec22faa19a1f2c"
4821
+ "gitHead": "39aa4a6200282fa60c92b648157a3627a13b5083"
4822
4822
  }