@ai-matrx/chat 0.7.11 → 0.7.13

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,33 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.7.13
4
+ ### Unreleased
5
+ ### a refused answer fails the run's own row (lane F13)
6
+
7
+ - **`recordRunOutputRefused(runRowId, reason, requestId?)`** writes against the `chat.user_request` row id the server announced
8
+ (`record_reserved`), read with the new **`runRowIdOf(state, requestId)`**. 0.7.10 wrote against the client's Redux key
9
+ (`req_<uuid>`), which no row has: every refused run on Make stayed `completed`, error null (live 2026-10-09). A refusal with
10
+ no row id is a `console.error`, never a silent no-op.
11
+ - Tests: `a-refused-answer-fails-the-servers-run-row.test.ts`.
12
+ - **Consumer action:** none (`useHeadlessAgentJson` is the only caller).
13
+
14
+ ## 0.7.12
15
+ ### Unreleased
16
+ ### The variables a run used (Agent Factory R59)
17
+
18
+ - **`selectVariablesTheRunUsed(conversationId)`** (instance-variable-values selectors): the values a run's first turn was sent
19
+ with, frozen at send; before a send, the request merge. The builder's Side Chat helper reads it so a later edit of the field
20
+ cannot rewrite what the run was given.
21
+ - **Consumer action:** none.
22
+
23
+ ### A persisted answer paints with its markdown (spend register item 32, cold-load CLS at 1440)
24
+
25
+ - **`MarkdownStream`**: the engine chunk is requested when a conversation surface's module loads (`preloadMarkdownStream`), kept once
26
+ resolved, and rendered synchronously on the first render of a new instance. The per-instance `mounted` effect gate (an extra commit
27
+ after paint) is now `useSyncExternalStore` (still client-only: server and hydration render nothing). Before, the answer block painted
28
+ as a 36 px header and grew to its full height ~200 ms later, shifting everything below it (CLS 0.06-0.28 at 1440).
29
+ - **Consumer action:** none.
30
+
3
31
  ## 0.7.11
4
32
  ### continue-agent-json is exported again
5
33
 
@@ -6,7 +6,7 @@ import {
6
6
  useState,
7
7
  useEffect
8
8
  } from "react";
9
- import MarkdownStream from "../../../../ui/markdown-stream/MarkdownStream.js";
9
+ import MarkdownStream, { preloadMarkdownStream } from "../../../../ui/markdown-stream/MarkdownStream.js";
10
10
  import "../rich-document/chat-message-source.js";
11
11
  import { RecordAnnotations, annotationRecordOf } from "@ai-matrx/rich-content/host/app-bindings";
12
12
  import { useAppDispatch, useAppSelector } from "../../../../store/hooks.js";
@@ -91,6 +91,7 @@ import {
91
91
  } from "../../../../host/ui-slots.js";
92
92
  import { ErrorAlchemyMenu } from "../../../../host/ui-slots.js";
93
93
  import { useInPlaceTrigger } from "@ai-matrx/rich-editor/in-place/EditInPlace";
94
+ void preloadMarkdownStream();
94
95
  const _NO_LIVE_SOURCES = [];
95
96
  const _selectNoLiveSources = () => _NO_LIVE_SOURCES;
96
97
  function retryErrorMessage(err) {
@@ -1,4 +1,4 @@
1
- import { type LiveAgentRunOptions } from "./useLiveAgentRun.js";
1
+ import { type LiveAgentReattachOptions, type LiveAgentRunOptions } from "./useLiveAgentRun.js";
2
2
  /**
3
3
  * The words a failed launch shows in its window: the server's own reason when
4
4
  * the run carried one (a Mandate with no Holder names the rungs it checked),
@@ -83,6 +83,16 @@ export interface UseFloatingAgentRun {
83
83
  run: <T = unknown>(opts: LiveAgentRunOptions<T> & {
84
84
  label?: string;
85
85
  }) => Promise<T>;
86
+ /**
87
+ * Pick an EXISTING run back up — a reload or a paused tab must never start a
88
+ * second one. The conversation id is what `onConversationCreated` handed out
89
+ * at launch. Resolves with the finished result when the run is done, or
90
+ * follows it to the end when it is still going, in the same window. No run is
91
+ * started.
92
+ */
93
+ reattach: <T = unknown>(conversationId: string, opts: LiveAgentReattachOptions<T> & {
94
+ label?: string;
95
+ }) => Promise<T>;
86
96
  isRunning: boolean;
87
97
  error: string | null;
88
98
  conversationId: string | null;
@@ -123,8 +123,20 @@ function useFloatingAgentRun(options = {}) {
123
123
  throw error;
124
124
  }
125
125
  }
126
+ async function reattach(existingConversationId, opts) {
127
+ const { label, ...reattachOptions } = opts;
128
+ const bound = floating.start(label ?? options.label ?? "AI is working");
129
+ bound.bind(existingConversationId);
130
+ try {
131
+ return await live.reattach(existingConversationId, reattachOptions);
132
+ } catch (error) {
133
+ bound.fail(floatingFailureReason(error));
134
+ throw error;
135
+ }
136
+ }
126
137
  return {
127
138
  run,
139
+ reattach,
128
140
  isRunning: live.isRunning,
129
141
  error: live.error,
130
142
  conversationId: live.conversationId,
@@ -1,3 +1,4 @@
1
+ import { type ReattachHeadlessAgentJsonOptions } from "../redux/execution-system/thunks/reattach-headless-agent-json.js";
1
2
  import { type HeadlessAgentJsonOptions, type HeadlessAgentJsonResult } from "../redux/execution-system/thunks/run-headless-agent-json.js";
2
3
  /**
3
4
  * What `run()` throws. It carries the run's TECHNICAL reason (when the run
@@ -27,8 +28,21 @@ export interface HeadlessAgentJsonRunOptions<T> extends HeadlessAgentJsonOptions
27
28
  */
28
29
  coerce?: (value: unknown, result: HeadlessAgentJsonResult) => T;
29
30
  }
31
+ /**
32
+ * Reopen a run that ALREADY exists (running or finished) — never starts one.
33
+ * `conversationId` is what `onConversationCreated` handed out at launch.
34
+ */
35
+ export interface HeadlessAgentJsonReattachOptions<T> extends Omit<ReattachHeadlessAgentJsonOptions, "conversationId"> {
36
+ coerce?: (value: unknown, result: HeadlessAgentJsonResult) => T;
37
+ }
30
38
  export interface UseHeadlessAgentJson {
31
39
  run: <T = unknown>(opts: HeadlessAgentJsonRunOptions<T>) => Promise<T>;
40
+ /**
41
+ * Pick an existing run back up: its finished result when it is done, or
42
+ * follow it to the end when it is still going. No new run is ever started.
43
+ * Throws exactly like `run`.
44
+ */
45
+ reattach: <T = unknown>(conversationId: string, opts: HeadlessAgentJsonReattachOptions<T>) => Promise<T>;
32
46
  isRunning: boolean;
33
47
  error: string | null;
34
48
  /** Conversation id of the in-flight run — set BEFORE the stream starts. */
@@ -3,7 +3,10 @@ import { useCallback, useState } from "react";
3
3
  import { useAppDispatch, useAppSelector, useAppStore } from "../../store/hooks.js";
4
4
  import { selectConversationRequestIds } from "../redux/execution-system/active-requests/active-requests.selectors.js";
5
5
  import { useRetainRequestForViewer } from "../redux/execution-system/active-requests/useRetainRequestForViewer.js";
6
- import { recordRunOutputRefused } from "../redux/execution-system/thunks/record-run-output-refused.js";
6
+ import { recordRunOutputRefused, runRowIdOf } from "../redux/execution-system/thunks/record-run-output-refused.js";
7
+ import {
8
+ reattachHeadlessAgentJson
9
+ } from "../redux/execution-system/thunks/reattach-headless-agent-json.js";
7
10
  import {
8
11
  runHeadlessAgentJson
9
12
  } from "../redux/execution-system/thunks/run-headless-agent-json.js";
@@ -31,33 +34,29 @@ function useHeadlessAgentJson() {
31
34
  return ids.length > 0 ? ids[ids.length - 1] : null;
32
35
  });
33
36
  useRetainRequestForViewer(activeRequestId, "useHeadlessAgentJson");
34
- async function run(opts) {
37
+ async function settle(result, coerce) {
38
+ if (!result.success) {
39
+ throw new HeadlessAgentRunError(result.error ?? "The agent run failed", {
40
+ ...result.errorDetail ? { detail: result.errorDetail } : {},
41
+ ...result.requestId ? { requestId: result.requestId } : {},
42
+ ...result.conversationId ? { conversationId: result.conversationId } : {}
43
+ });
44
+ }
45
+ if (!coerce) return result.data;
46
+ try {
47
+ return coerce(result.data, result);
48
+ } catch (refused) {
49
+ const why = refused instanceof Error ? refused.message : String(refused);
50
+ const rowId = result.requestId ? runRowIdOf(store.getState(), result.requestId) : null;
51
+ void recordRunOutputRefused(rowId, why, result.requestId);
52
+ throw refused;
53
+ }
54
+ }
55
+ async function guarded(work) {
35
56
  setIsRunning(true);
36
57
  setError(null);
37
- setConversationId(null);
38
58
  try {
39
- const result = await runHeadlessAgentJson(dispatch, store.getState, {
40
- ...opts,
41
- onConversationCreated: (cid) => {
42
- setConversationId(cid);
43
- opts.onConversationCreated?.(cid);
44
- }
45
- });
46
- if (!result.success) {
47
- throw new HeadlessAgentRunError(result.error ?? "The agent run failed", {
48
- ...result.errorDetail ? { detail: result.errorDetail } : {},
49
- ...result.requestId ? { requestId: result.requestId } : {},
50
- ...result.conversationId ? { conversationId: result.conversationId } : {}
51
- });
52
- }
53
- if (!opts.coerce) return result.data;
54
- try {
55
- return opts.coerce(result.data, result);
56
- } catch (refused) {
57
- const why = refused instanceof Error ? refused.message : String(refused);
58
- if (result.requestId) void recordRunOutputRefused(result.requestId, why);
59
- throw refused;
60
- }
59
+ return await work();
61
60
  } catch (e) {
62
61
  const message = e instanceof Error ? e.message : "Failed to run the agent";
63
62
  setError(message);
@@ -66,11 +65,34 @@ function useHeadlessAgentJson() {
66
65
  setIsRunning(false);
67
66
  }
68
67
  }
68
+ async function run(opts) {
69
+ setConversationId(null);
70
+ return guarded(async () => {
71
+ const result = await runHeadlessAgentJson(dispatch, store.getState, {
72
+ ...opts,
73
+ onConversationCreated: (cid) => {
74
+ setConversationId(cid);
75
+ opts.onConversationCreated?.(cid);
76
+ }
77
+ });
78
+ return settle(result, opts.coerce);
79
+ });
80
+ }
81
+ async function reattach(existingConversationId, opts) {
82
+ setConversationId(existingConversationId);
83
+ return guarded(async () => {
84
+ const result = await reattachHeadlessAgentJson(dispatch, store.getState, {
85
+ ...opts,
86
+ conversationId: existingConversationId
87
+ });
88
+ return settle(result, opts.coerce);
89
+ });
90
+ }
69
91
  const reset = useCallback(() => {
70
92
  setError(null);
71
93
  setConversationId(null);
72
94
  }, []);
73
- return { run, isRunning, error, conversationId, activeRequestId, reset };
95
+ return { run, reattach, isRunning, error, conversationId, activeRequestId, reset };
74
96
  }
75
97
  export {
76
98
  HeadlessAgentRunError,
@@ -1,6 +1,13 @@
1
- import { type HeadlessAgentJsonRunOptions } from "./useHeadlessAgentJson.js";
1
+ import { type HeadlessAgentJsonReattachOptions, type HeadlessAgentJsonRunOptions } from "./useHeadlessAgentJson.js";
2
2
  export type LiveAgentRunOptions<T> = Omit<HeadlessAgentJsonRunOptions<T>, "displayMode" | "keepInstance">;
3
+ export type LiveAgentReattachOptions<T> = Omit<HeadlessAgentJsonReattachOptions<T>, "keepInstance">;
3
4
  export interface UseLiveAgentRun {
5
+ /**
6
+ * Pick an EXISTING run back up (a reload, a paused tab) — its finished
7
+ * result, or follow it to the end. Never starts a run; the live handle
8
+ * (`conversationId`) is set before anything is read.
9
+ */
10
+ reattach: <T = unknown>(conversationId: string, opts: LiveAgentReattachOptions<T>) => Promise<T>;
4
11
  /** Run the agent; the live handle (`conversationId`) is set before the stream. */
5
12
  run: <T = unknown>(opts: LiveAgentRunOptions<T>) => Promise<T>;
6
13
  isRunning: boolean;
@@ -38,6 +38,20 @@ function useLiveAgentRun() {
38
38
  }
39
39
  }
40
40
  }
41
+ async function reattach(existingConversationId, opts) {
42
+ if (ownedConversationRef.current !== existingConversationId) releaseOwned();
43
+ setDismissed(false);
44
+ dismissedRef.current = false;
45
+ ownedConversationRef.current = existingConversationId;
46
+ try {
47
+ return await headless.reattach(existingConversationId, {
48
+ ...opts,
49
+ keepInstance: true
50
+ });
51
+ } finally {
52
+ if (dismissedRef.current) releaseOwned();
53
+ }
54
+ }
41
55
  const dismiss = () => {
42
56
  setDismissed(true);
43
57
  dismissedRef.current = true;
@@ -58,6 +72,7 @@ function useLiveAgentRun() {
58
72
  );
59
73
  return {
60
74
  run,
75
+ reattach,
61
76
  dismiss,
62
77
  isRunning: headless.isRunning,
63
78
  error: headless.error,
@@ -105,6 +105,76 @@ export declare const selectOwnVariableValues: (conversationId: string) => ((stat
105
105
  memoize: typeof import("reselect").weakMapMemoize;
106
106
  argsMemoize: typeof import("reselect").weakMapMemoize;
107
107
  };
108
+ /**
109
+ * The variables a run USED: the values its first turn was sent with — frozen at
110
+ * send, host-wired names included — so a later edit of the variable field never
111
+ * rewrites what the run was given. Before any send it is what the next send would
112
+ * carry (the request merge, defaults included). Read by the agent builder's Side
113
+ * Chat helper to judge a test run.
114
+ */
115
+ export declare const selectVariablesTheRunUsed: (conversationId: string) => ((state: {
116
+ voiceAgent: import("../../../../voice-agent/types.js").VoiceAgentState;
117
+ agentDefinition: import("../../../types/agent-definition.types.js").AgentDefinitionSliceState;
118
+ conversationList: import("../../conversation-list/conversation-list.types.js").ConversationListState;
119
+ conversationHistory: import("../../conversation-history/types.js").ConversationHistoryState;
120
+ agentShortcut: import("../../agent-shortcuts/types.js").AgentShortcutSliceState;
121
+ agentShortcutCategory: import("../../agent-shortcut-categories/types.js").AgentShortcutCategorySliceState;
122
+ surfaceUserState: import("../../../../surfaces/redux/userStateSlice.js").SurfaceUserStateSlice;
123
+ conversations: import("../conversations/conversations.slice.js").ConversationsState;
124
+ chatIncognito: import("../../chat/chat-incognito.slice.js").ChatIncognitoState;
125
+ chatRoute: import("../../chat/chat-route.slice.js").ChatRouteState;
126
+ instanceModelOverrides: import("../instance-model-overrides/instance-model-overrides.slice.js").InstanceModelOverridesState;
127
+ instanceInputCapabilities: import("../instance-input-capabilities/instance-input-capabilities.slice.js").InstanceInputCapabilitiesState;
128
+ instanceVariableValues: import("./instance-variable-values.slice.js").InstanceVariableValuesState;
129
+ instanceResources: import("../instance-resources/instance-resources.slice.js").InstanceResourcesState;
130
+ instanceContext: import("../instance-context/instance-context.slice.js").InstanceContextState;
131
+ instanceWorkingDocument: import("../instance-working-document/instance-working-document.slice.js").InstanceWorkingDocumentSliceState;
132
+ instanceUserInput: import("../instance-user-input/instance-user-input.slice.js").InstanceUserInputSliceState;
133
+ instanceClientTools: import("../instance-client-tools/instance-client-tools.slice.js").InstanceClientToolsState;
134
+ pendingAsks: import("../../../ui-first-tools/redux/pending-asks.slice.js").PendingAsksState;
135
+ conversationInbox: import("../inbox/inbox.slice.js").ConversationInboxState;
136
+ agentLists: import("../../../ui-first-tools/redux/agent-lists.slice.js").AgentListsState;
137
+ instanceUIState: import("../instance-ui-state/instance-ui-state.slice.js").InstanceUIStateSlice;
138
+ activeTools: import("../active-tools/active-tools.slice.js").ActiveToolsState;
139
+ activeRequests: import("../active-requests/active-requests.slice.js").ActiveRequestsState;
140
+ runSets: import("../run-sets/run-sets.slice.js").RunSetsState;
141
+ messages: import("../messages/messages.slice.js").MessagesState;
142
+ observability: import("../observability/observability.slice.js").ObservabilityState;
143
+ contextState: import("../context-state/context-state.slice.js").ContextStateSliceState;
144
+ observationalMemory: import("../observational-memory/observational-memory.slice.js").ObservationalMemoryState;
145
+ cacheBypass: import("../message-crud/cache-bypass.slice.js").CacheBypassState;
146
+ conversationFocus: import("../conversation-focus/conversation-focus.slice.js").ConversationFocusState;
147
+ surfaces: import("../../surfaces/surfaces.slice.js").SurfacesState;
148
+ surfacesCatalog: import("../../../../surfaces/redux/surfacesCatalogSlice.js").SurfacesCatalogSliceState;
149
+ agentSurfaceBindings: import("../../../../surfaces/redux/agentSurfaceBindingsSlice.js").AgentSurfaceBindingsSliceState;
150
+ surfaceConfig: import("../../../../surfaces/redux/surfaceConfigSlice.js").SurfaceConfigSliceState;
151
+ agentAssistantMarkdownDraft: import("../../agent-assistant-markdown-draft.slice.js").AgentAssistantMarkdownDraftState;
152
+ mcp: import("../../mcp/mcp.slice.js").McpSliceState;
153
+ proposedDirectives: import("../../proposed-directives/proposedDirectivesSlice.js").ProposedDirectivesState;
154
+ conversationAttachments: import("../../../connectors/attachments.slice.js").AttachmentsSliceState;
155
+ chatHost: import("../../../../store/chat-host.slice.js").ChatHostState;
156
+ hostConversations: import("../host-conversations/host-conversations.slice.js").HostConversationsState;
157
+ } & import("../../../../store/root-state.js").ChatHostReadSlices) => Record<string, unknown>) & {
158
+ clearCache: () => void;
159
+ resultsCount: () => number;
160
+ resetResultsCount: () => void;
161
+ } & {
162
+ resultFunc: (resultFuncArgs_0: import("./instance-variable-values.slice.js").InstanceVariableValuesEntry) => Record<string, unknown>;
163
+ memoizedResultFunc: ((resultFuncArgs_0: import("./instance-variable-values.slice.js").InstanceVariableValuesEntry) => Record<string, unknown>) & {
164
+ clearCache: () => void;
165
+ resultsCount: () => number;
166
+ resetResultsCount: () => void;
167
+ };
168
+ lastResult: () => Record<string, unknown>;
169
+ dependencies: [(state: ChatRootState) => import("./instance-variable-values.slice.js").InstanceVariableValuesEntry];
170
+ recomputations: () => number;
171
+ resetRecomputations: () => void;
172
+ dependencyRecomputations: () => number;
173
+ resetDependencyRecomputations: () => void;
174
+ } & {
175
+ memoize: typeof import("reselect").weakMapMemoize;
176
+ argsMemoize: typeof import("reselect").weakMapMemoize;
177
+ };
108
178
  /** Immutable first-turn values, excluding names the host supplied. */
109
179
  export declare const selectOwnSubmittedFirstTurnValues: (conversationId: string) => ((state: {
110
180
  voiceAgent: import("../../../../voice-agent/types.js").VoiceAgentState;
@@ -25,6 +25,19 @@ const selectOwnVariableValues = (conversationId) => createSelector(
25
25
  return Object.keys(own).length === 0 ? EMPTY_RECORD : own;
26
26
  }
27
27
  );
28
+ const selectVariablesTheRunUsed = (conversationId) => createSelector(
29
+ (state) => state.instanceVariableValues.byConversationId[conversationId],
30
+ (entry) => {
31
+ if (!entry) return EMPTY_RECORD;
32
+ if (entry.submittedFirstTurnValues) return entry.submittedFirstTurnValues;
33
+ const merged = resolveVariablesForRequest({
34
+ definitions: entry.definitions,
35
+ userValues: entry.userValues,
36
+ scopeValues: entry.scopeValues
37
+ });
38
+ return Object.keys(merged).length > 0 ? merged : EMPTY_RECORD;
39
+ }
40
+ );
28
41
  const selectOwnSubmittedFirstTurnValues = (conversationId) => createSelector(
29
42
  (state) => state.instanceVariableValues.byConversationId[conversationId],
30
43
  (entry) => {
@@ -134,5 +147,6 @@ export {
134
147
  selectScopeVariableValues,
135
148
  selectUserVariableValues,
136
149
  selectVariableProvenance,
137
- selectVariablesForRequest
150
+ selectVariablesForRequest,
151
+ selectVariablesTheRunUsed
138
152
  };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * reattachHeadlessAgentJson — the sibling of `runHeadlessAgentJson` for a run
3
+ * that is ALREADY running or finished: a reload or a paused tab must never
4
+ * start a second paid run, it picks the first one back up.
5
+ *
6
+ * It owns no run path of its own. Every step is an existing primitive:
7
+ *
8
+ * 1. `loadConversation` — the saved conversation (a finished run's
9
+ * last message is read from it)
10
+ * 2. `surfaceColdPendingCalls` — a delegated tool prompt nobody answered
11
+ * 3. `reconnectServerOperation` — `/runtime/operations/by-link/conversation`:
12
+ * if the server is still working, rejoin
13
+ * and follow it to terminal through the
14
+ * canonical stream pipeline (so the same
15
+ * LiveRunWindow bound to this conversation
16
+ * streams it), then reload the saved turn
17
+ * 4. `adoptHeadlessAgentJson` — THE result-resolution rule, over the
18
+ * request that is now in Redux
19
+ *
20
+ * A stream this very page still owns (`hasAbortController`) is never re-read:
21
+ * a reload would clobber it, so steps 1–3 are skipped and the live request is
22
+ * simply waited on. Never throws — resolves a structured result.
23
+ */
24
+ import type { ChatDispatch, ChatRootState } from "../../../../store/root-state.js";
25
+ import { type AdoptedAgentJsonOptions, type HeadlessAgentJsonResult } from "./run-headless-agent-json.js";
26
+ export interface ReattachHeadlessAgentJsonOptions extends Omit<AdoptedAgentJsonOptions, "requestId" | "conversationId"> {
27
+ conversationId: string;
28
+ }
29
+ export declare function reattachHeadlessAgentJson(dispatch: ChatDispatch, getState: () => ChatRootState, opts: ReattachHeadlessAgentJsonOptions): Promise<HeadlessAgentJsonResult>;
@@ -0,0 +1,89 @@
1
+ import { extractErrorMessage } from "@ai-matrx/data/net";
2
+ import { extractFirstJson } from "@ai-matrx/content-ir/json-extract";
3
+ import { captureError } from "../../../../host/diagnostics.js";
4
+ import { reconnectServerOperation } from "../../../runtime-reconnect/reconnect-server-operation.thunk.js";
5
+ import { selectRequestStatus } from "../active-requests/active-requests.selectors.js";
6
+ import { selectLatestAnswerText as selectCommittedAnswerText } from "../messages/messages.selectors.js";
7
+ import { selectLatestRequestId } from "../selectors/aggregate.selectors.js";
8
+ import { hasAbortController } from "./abort-registry.js";
9
+ import { loadConversation } from "./load-conversation.thunk.js";
10
+ import { surfaceColdPendingCalls } from "./surface-cold-pending-calls.thunk.js";
11
+ import {
12
+ adoptHeadlessAgentJson
13
+ } from "./run-headless-agent-json.js";
14
+ const REATTACH_SETTLE_MS = 300;
15
+ async function reattachHeadlessAgentJson(dispatch, getState, opts) {
16
+ const { conversationId } = opts;
17
+ const failure = (error, errorDetail) => ({
18
+ success: false,
19
+ data: null,
20
+ fullResponse: selectCommittedAnswerText(conversationId)(getState()),
21
+ error,
22
+ errorDetail,
23
+ conversationId
24
+ });
25
+ if (!hasAbortController(conversationId)) {
26
+ try {
27
+ await dispatch(loadConversation({ conversationId })).unwrap();
28
+ } catch (err) {
29
+ const detail = extractErrorMessage(err);
30
+ captureError({
31
+ source: "agent-json-result",
32
+ message: `Reattach could not read conversation ${conversationId}: ${detail}`,
33
+ conversationId,
34
+ raw: { surfaceKey: opts.surfaceKey, detail }
35
+ });
36
+ return failure("That run could not be opened again.", detail);
37
+ }
38
+ void dispatch(surfaceColdPendingCalls(conversationId));
39
+ try {
40
+ await dispatch(
41
+ reconnectServerOperation({ conversationId, source: "cold-load" })
42
+ ).unwrap();
43
+ } catch (err) {
44
+ const detail = extractErrorMessage(err);
45
+ captureError({
46
+ source: "agent-json-result",
47
+ message: `Reattach could not follow conversation ${conversationId}: ${detail}`,
48
+ conversationId,
49
+ raw: { surfaceKey: opts.surfaceKey, detail }
50
+ });
51
+ return failure("That run could not be followed to its end.", detail);
52
+ }
53
+ }
54
+ const requestId = selectLatestRequestId(conversationId)(getState());
55
+ const status = requestId ? selectRequestStatus(requestId)(getState()) : void 0;
56
+ const fullResponse = selectCommittedAnswerText(conversationId)(getState());
57
+ const finished = status === void 0 || status === "complete";
58
+ if (fullResponse.trim() && finished && !hasAbortController(conversationId)) {
59
+ const expect = opts.expect ?? "json";
60
+ const data = expect === "text" ? fullResponse.trim() : extractFirstJson(fullResponse, { allowFuzzy: true })?.value ?? null;
61
+ if (data != null) {
62
+ const result = {
63
+ success: true,
64
+ data,
65
+ fullResponse,
66
+ conversationId,
67
+ ...requestId ? { requestId } : {}
68
+ };
69
+ await opts.onResult?.(result);
70
+ return result;
71
+ }
72
+ }
73
+ if (requestId) {
74
+ return adoptHeadlessAgentJson(dispatch, getState, {
75
+ ...opts,
76
+ requestId,
77
+ settleMs: opts.settleMs ?? REATTACH_SETTLE_MS,
78
+ // The window keeps showing the finished run; the hook owns teardown.
79
+ keepInstance: opts.keepInstance ?? true
80
+ });
81
+ }
82
+ return failure(
83
+ "That run has no saved result to open.",
84
+ "reattach: the conversation holds no request and no readable answer"
85
+ );
86
+ }
87
+ export {
88
+ reattachHeadlessAgentJson
89
+ };
@@ -10,5 +10,14 @@
10
10
  *
11
11
  * Never silent: a write that does not land is a `console.error` naming the run.
12
12
  */
13
+ import type { ChatRootState } from "../../../../store/root-state.js";
13
14
  export declare const OUTPUT_REFUSED_METADATA_KEY = "output_refused";
14
- export declare function recordRunOutputRefused(requestId: string, reason: string): Promise<boolean>;
15
+ /**
16
+ * The run's own `chat.user_request` row id, as the server announced it (`record_reserved` for the
17
+ * `user_request` table, or a message reservation's `parent_refs.user_request_id`). The client's
18
+ * `requestId` (`req_<uuid>`) is a Redux key the database has never seen — writing against it matched
19
+ * no row, so every refusal before 0.7.12 left the run `completed` (Make's describe box, 2026-10-09).
20
+ * Null when the stream announced no row.
21
+ */
22
+ export declare function runRowIdOf(state: ChatRootState, requestId: string): string | null;
23
+ export declare function recordRunOutputRefused(runRowId: string | null, reason: string, requestId?: string): Promise<boolean>;
@@ -2,17 +2,34 @@ import { mergeJsonColumn } from "@ai-matrx/data/db";
2
2
  import { supabase } from "../../../../host/db.js";
3
3
  import { hasBrowserSession } from "../../../../host/identity.js";
4
4
  const OUTPUT_REFUSED_METADATA_KEY = "output_refused";
5
- async function recordRunOutputRefused(requestId, reason) {
6
- if (!requestId || !await hasBrowserSession()) return false;
5
+ const ROW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ function runRowIdOf(state, requestId) {
7
+ const reservations = state.activeRequests.byRequestId[requestId]?.reservations;
8
+ if (!reservations) return null;
9
+ const all = Object.values(reservations);
10
+ const own = all.find((r) => r.table === "user_request" && ROW_ID.test(r.recordId));
11
+ if (own) return own.recordId;
12
+ const parent = all.map((r) => r.parentRefs?.user_request_id).find((id) => typeof id === "string" && ROW_ID.test(id));
13
+ return parent ?? null;
14
+ }
15
+ async function recordRunOutputRefused(runRowId, reason, requestId) {
16
+ if (!runRowId || !ROW_ID.test(runRowId)) {
17
+ console.error(
18
+ `[run-output-refused] Run ${requestId ?? "?"} announced no chat.user_request row id (got ${runRowId ?? "none"}); the refusal cannot be recorded and the run still reads as completed. Reason: ${reason}`
19
+ );
20
+ return false;
21
+ }
22
+ const requestRow = runRowId;
23
+ if (!await hasBrowserSession()) return false;
7
24
  const result = await mergeJsonColumn({
8
- fetchCurrent: () => supabase.schema("chat").from("user_request").select("id, version, metadata").eq("id", requestId).maybeSingle(),
25
+ fetchCurrent: () => supabase.schema("chat").from("user_request").select("id, version, metadata").eq("id", requestRow).maybeSingle(),
9
26
  readColumn: (row) => row.metadata,
10
27
  merge: (current) => ({ ...current, [OUTPUT_REFUSED_METADATA_KEY]: { reason, at: (/* @__PURE__ */ new Date()).toISOString() } }),
11
- applyUpdate: ({ value, expectedVersion, nextVersion }) => supabase.schema("chat").from("user_request").update({ metadata: value, version: nextVersion, status: "failed", error: reason.slice(0, 500) }).eq("id", requestId).eq("version", expectedVersion).select("id, version, metadata").maybeSingle()
28
+ applyUpdate: ({ value, expectedVersion, nextVersion }) => supabase.schema("chat").from("user_request").update({ metadata: value, version: nextVersion, status: "failed", error: reason.slice(0, 500) }).eq("id", requestRow).eq("version", expectedVersion).select("id, version, metadata").maybeSingle()
12
29
  });
13
30
  if (result.status !== "saved") {
14
31
  console.error(
15
- `[run-output-refused] Could not record the refusal on run ${requestId}: ` + (result.status === "error" ? String(result.error) : `write ${result.status}`) + `. The run still reads as completed. Reason: ${reason}`
32
+ `[run-output-refused] Could not record the refusal on run ${requestRow}: ` + (result.status === "error" ? String(result.error) : `write ${result.status}`) + `. The run still reads as completed. Reason: ${reason}`
16
33
  );
17
34
  return false;
18
35
  }
@@ -20,5 +37,6 @@ async function recordRunOutputRefused(requestId, reason) {
20
37
  }
21
38
  export {
22
39
  OUTPUT_REFUSED_METADATA_KEY,
23
- recordRunOutputRefused
40
+ recordRunOutputRefused,
41
+ runRowIdOf
24
42
  };
@@ -83,6 +83,7 @@ export interface MarkdownStreamProps {
83
83
  */
84
84
  imagePolicy?: "self" | "other" | "ai" | "inherit";
85
85
  }
86
- declare function MarkdownStream(props: MarkdownStreamProps): import("react").FunctionComponentElement<import("react").SuspenseProps> | null;
86
+ export declare function preloadMarkdownStream(): Promise<void>;
87
+ declare function MarkdownStream(props: MarkdownStreamProps): import("react").FunctionComponentElement<import("react").SuspenseProps> | import("react").ReactElement<MarkdownStreamProps, string | import("react").JSXElementConstructor<any>> | null;
87
88
  export { MarkdownStream };
88
89
  export default MarkdownStream;
@@ -1,14 +1,33 @@
1
1
  "use client";
2
- import { createElement, lazy, Suspense, useEffect, useState } from "react";
2
+ import { createElement, lazy, Suspense, useSyncExternalStore } from "react";
3
3
  const MarkdownStreamImpl = lazy(() => import("./MarkdownStreamImpl.js"));
4
+ let loadedImpl = null;
5
+ let loading = null;
6
+ const readyListeners = /* @__PURE__ */ new Set();
7
+ function preloadMarkdownStream() {
8
+ if (typeof window === "undefined") return Promise.resolve();
9
+ loading ??= import("./MarkdownStreamImpl.js").then((m) => {
10
+ loadedImpl = m.default;
11
+ for (const notify of readyListeners) notify();
12
+ });
13
+ return loading;
14
+ }
15
+ const subscribeReady = (notify) => {
16
+ readyListeners.add(notify);
17
+ return () => readyListeners.delete(notify);
18
+ };
19
+ const clientReady = () => true;
20
+ const serverNotReady = () => false;
4
21
  function MarkdownStream(props) {
5
- const [mounted, setMounted] = useState(false);
6
- useEffect(() => setMounted(true), []);
7
- if (!mounted) return null;
22
+ const client = useSyncExternalStore(subscribeReady, clientReady, serverNotReady);
23
+ if (!client) return null;
24
+ if (loadedImpl) return createElement(loadedImpl, props);
25
+ void preloadMarkdownStream();
8
26
  return createElement(Suspense, { fallback: null }, createElement(MarkdownStreamImpl, props));
9
27
  }
10
28
  var MarkdownStream_default = MarkdownStream;
11
29
  export {
12
30
  MarkdownStream,
13
- MarkdownStream_default as default
31
+ MarkdownStream_default as default,
32
+ preloadMarkdownStream
14
33
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
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",
@@ -4686,20 +4686,20 @@
4686
4686
  "@ai-matrx/browser-audio": "latest",
4687
4687
  "@ai-matrx/canvas": "latest",
4688
4688
  "@ai-matrx/content-ir": "latest",
4689
- "@ai-matrx/agents": "latest",
4690
4689
  "@ai-matrx/content-ir-react": "latest",
4691
- "@ai-matrx/data": "latest",
4690
+ "@ai-matrx/agents": "latest",
4692
4691
  "@ai-matrx/design-system": "latest",
4692
+ "@ai-matrx/diff": "latest",
4693
4693
  "@ai-matrx/icons": "latest",
4694
4694
  "@ai-matrx/kit": "latest",
4695
4695
  "@ai-matrx/media": "latest",
4696
- "@ai-matrx/print": "latest",
4697
4696
  "@ai-matrx/realtime": "latest",
4698
4697
  "@ai-matrx/records": "latest",
4699
- "@ai-matrx/records-ui": "latest",
4698
+ "@ai-matrx/data": "latest",
4700
4699
  "@ai-matrx/rich-content": "latest",
4700
+ "@ai-matrx/print": "latest",
4701
4701
  "@ai-matrx/rich-editor": "latest",
4702
- "@ai-matrx/diff": "latest"
4702
+ "@ai-matrx/records-ui": "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": "db3bd1fad7851f7648e0aeee094cf4df6152b3ae"
4821
+ "gitHead": "b49e03c743ac750bc1615fd9635bdc5a4796a241"
4822
4822
  }