@ai-matrx/chat 0.7.12 → 0.7.14
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 +23 -0
- package/dist/agents/components/messages-display/assistant/AgentAssistantMessage.d.ts +6 -1
- package/dist/agents/components/messages-display/assistant/AgentAssistantMessage.js +8 -2
- package/dist/agents/hooks/useFloatingAgentRun.d.ts +11 -1
- package/dist/agents/hooks/useFloatingAgentRun.js +12 -0
- package/dist/agents/hooks/useHeadlessAgentJson.d.ts +14 -0
- package/dist/agents/hooks/useHeadlessAgentJson.js +48 -26
- package/dist/agents/hooks/useLiveAgentRun.d.ts +8 -1
- package/dist/agents/hooks/useLiveAgentRun.js +15 -0
- package/dist/agents/redux/execution-system/thunks/reattach-headless-agent-json.d.ts +29 -0
- package/dist/agents/redux/execution-system/thunks/reattach-headless-agent-json.js +89 -0
- package/dist/agents/redux/execution-system/thunks/record-run-output-refused.d.ts +10 -1
- package/dist/agents/redux/execution-system/thunks/record-run-output-refused.js +24 -6
- package/dist/ui/markdown-stream/MarkdownStream.d.ts +2 -1
- package/dist/ui/markdown-stream/MarkdownStream.js +5 -20
- package/dist/ui/markdown-stream/markdown-engine.d.ts +19 -0
- package/dist/ui/markdown-stream/markdown-engine.js +44 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @ai-matrx/chat
|
|
2
2
|
|
|
3
|
+
## 0.7.14
|
|
4
|
+
### Unreleased
|
|
5
|
+
### An answer never paints header-first (spend register item 32, cold-load CLS at 1280/1440/1920)
|
|
6
|
+
|
|
7
|
+
- **`AgentAssistantMessage`** renders nothing until the markdown engine chunk has settled (`useMarkdownEngineSettled`, new internal
|
|
8
|
+
`ui/markdown-stream/markdown-engine`), so every message of a conversation appears in the same commit. A chunk that fails to load is a
|
|
9
|
+
`console.error` and the message renders anyway. 0.7.12 preloaded the chunk but a message could still render before it landed
|
|
10
|
+
(a 82 px header-only block growing to 385 px: CLS up to 0.32 on a cold load).
|
|
11
|
+
- `MarkdownStream` keeps the loaded engine and renders it synchronously on a new instance's first render.
|
|
12
|
+
- Test: `ui/markdown-stream/__tests__/markdown-engine-first-paint.test.tsx`.
|
|
13
|
+
- **Consumer action:** none.
|
|
14
|
+
|
|
15
|
+
## 0.7.13
|
|
16
|
+
### Unreleased
|
|
17
|
+
### a refused answer fails the run's own row (lane F13)
|
|
18
|
+
|
|
19
|
+
- **`recordRunOutputRefused(runRowId, reason, requestId?)`** writes against the `chat.user_request` row id the server announced
|
|
20
|
+
(`record_reserved`), read with the new **`runRowIdOf(state, requestId)`**. 0.7.10 wrote against the client's Redux key
|
|
21
|
+
(`req_<uuid>`), which no row has: every refused run on Make stayed `completed`, error null (live 2026-10-09). A refusal with
|
|
22
|
+
no row id is a `console.error`, never a silent no-op.
|
|
23
|
+
- Tests: `a-refused-answer-fails-the-servers-run-row.test.ts`.
|
|
24
|
+
- **Consumer action:** none (`useHeadlessAgentJson` is the only caller).
|
|
25
|
+
|
|
3
26
|
## 0.7.12
|
|
4
27
|
### Unreleased
|
|
5
28
|
### The variables a run used (Agent Factory R59)
|
|
@@ -41,4 +41,9 @@ export interface AgentAssistantMessageProps {
|
|
|
41
41
|
*/
|
|
42
42
|
isTurnAnswer?: boolean;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* First-paint rule (item 32): an answer never paints as a header-only block that grows when the
|
|
46
|
+
* markdown engine lands, so a message renders nothing until the engine has settled. Every message of
|
|
47
|
+
* a conversation waits on the same flag, so they appear in the same commit.
|
|
48
|
+
*/
|
|
49
|
+
export declare function AgentAssistantMessage(props: AgentAssistantMessageProps): import("react").JSX.Element | null;
|
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
useState,
|
|
7
7
|
useEffect
|
|
8
8
|
} from "react";
|
|
9
|
-
import MarkdownStream
|
|
9
|
+
import MarkdownStream from "../../../../ui/markdown-stream/MarkdownStream.js";
|
|
10
|
+
import { preloadMarkdownStream, useMarkdownEngineSettled } from "../../../../ui/markdown-stream/markdown-engine.js";
|
|
10
11
|
import "../rich-document/chat-message-source.js";
|
|
11
12
|
import { RecordAnnotations, annotationRecordOf } from "@ai-matrx/rich-content/host/app-bindings";
|
|
12
13
|
import { useAppDispatch, useAppSelector } from "../../../../store/hooks.js";
|
|
@@ -106,7 +107,12 @@ function providerControlErrorMessage(err) {
|
|
|
106
107
|
if (err instanceof Error) return err.message;
|
|
107
108
|
return "Provider control failed";
|
|
108
109
|
}
|
|
109
|
-
function AgentAssistantMessage({
|
|
110
|
+
function AgentAssistantMessage(props) {
|
|
111
|
+
const engineSettled = useMarkdownEngineSettled();
|
|
112
|
+
if (!engineSettled) return null;
|
|
113
|
+
return /* @__PURE__ */ jsx(AgentAssistantMessageBody, { ...props });
|
|
114
|
+
}
|
|
115
|
+
function AgentAssistantMessageBody({
|
|
110
116
|
conversationId,
|
|
111
117
|
requestId,
|
|
112
118
|
messageId,
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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",
|
|
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",
|
|
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 ${
|
|
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
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnswerEditRemarkMeta } from "../../agents/redux/execution-system/instance-resources/remarks.js";
|
|
2
|
+
import { preloadMarkdownStream } from "./markdown-engine.js";
|
|
2
3
|
import { TypedStreamEvent } from "@ai-matrx/rich-content/display/chat-markdown/types";
|
|
3
4
|
import type { ServerProcessedBlock } from "./EnhancedChatMarkdown.js";
|
|
4
5
|
/**
|
|
@@ -83,7 +84,7 @@ export interface MarkdownStreamProps {
|
|
|
83
84
|
*/
|
|
84
85
|
imagePolicy?: "self" | "other" | "ai" | "inherit";
|
|
85
86
|
}
|
|
86
|
-
export declare function preloadMarkdownStream(): Promise<void>;
|
|
87
87
|
declare function MarkdownStream(props: MarkdownStreamProps): import("react").FunctionComponentElement<import("react").SuspenseProps> | import("react").ReactElement<MarkdownStreamProps, string | import("react").JSXElementConstructor<any>> | null;
|
|
88
|
+
export { preloadMarkdownStream };
|
|
88
89
|
export { MarkdownStream };
|
|
89
90
|
export default MarkdownStream;
|
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { createElement, lazy, Suspense
|
|
2
|
+
import { createElement, lazy, Suspense } from "react";
|
|
3
|
+
import { loadedMarkdownImpl, preloadMarkdownStream, useIsMarkdownClient } from "./markdown-engine.js";
|
|
3
4
|
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;
|
|
21
5
|
function MarkdownStream(props) {
|
|
22
|
-
const client =
|
|
6
|
+
const client = useIsMarkdownClient();
|
|
23
7
|
if (!client) return null;
|
|
24
|
-
|
|
8
|
+
const Loaded = loadedMarkdownImpl();
|
|
9
|
+
if (Loaded) return createElement(Loaded, props);
|
|
25
10
|
void preloadMarkdownStream();
|
|
26
11
|
return createElement(Suspense, { fallback: null }, createElement(MarkdownStreamImpl, props));
|
|
27
12
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markdown engine's load state (spend register item 32 — first-paint rule).
|
|
3
|
+
*
|
|
4
|
+
* A persisted answer must paint WITH its markdown, never as an empty block that grows once the engine
|
|
5
|
+
* chunk lands (a measured CLS of 0.06-0.32 on the run page). So:
|
|
6
|
+
* - `preloadMarkdownStream()` starts the chunk when a conversation surface's module evaluates;
|
|
7
|
+
* - the resolved component is kept here and rendered synchronously by `MarkdownStream`;
|
|
8
|
+
* - `useMarkdownEngineSettled()` lets an assistant message render NOTHING until the engine has loaded
|
|
9
|
+
* (or failed — a failure is logged and the message renders, never hangs), so every message of a
|
|
10
|
+
* conversation appears in the same commit instead of header-first.
|
|
11
|
+
* Client-only: the server snapshot is "not ready", so a server render never pulls the engine in.
|
|
12
|
+
*/
|
|
13
|
+
import { type ComponentType } from "react";
|
|
14
|
+
export declare function preloadMarkdownStream(): Promise<void>;
|
|
15
|
+
export declare const loadedMarkdownImpl: () => ComponentType<any> | null;
|
|
16
|
+
/** False on the server and during hydration; true on the client. */
|
|
17
|
+
export declare function useIsMarkdownClient(): boolean;
|
|
18
|
+
/** True once the engine chunk has loaded (or failed). False on the server and until then. */
|
|
19
|
+
export declare function useMarkdownEngineSettled(): boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
let loadedImpl = null;
|
|
4
|
+
let settled = false;
|
|
5
|
+
let loading = null;
|
|
6
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
7
|
+
const notify = () => {
|
|
8
|
+
for (const listener of listeners) listener();
|
|
9
|
+
};
|
|
10
|
+
function preloadMarkdownStream() {
|
|
11
|
+
if (typeof window === "undefined") return Promise.resolve();
|
|
12
|
+
loading ??= import("./MarkdownStreamImpl.js").then(
|
|
13
|
+
(m) => {
|
|
14
|
+
loadedImpl = m.default;
|
|
15
|
+
settled = true;
|
|
16
|
+
notify();
|
|
17
|
+
},
|
|
18
|
+
(error) => {
|
|
19
|
+
console.error("[chat] the markdown engine chunk failed to load; messages render without waiting for it", error);
|
|
20
|
+
settled = true;
|
|
21
|
+
notify();
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
return loading;
|
|
25
|
+
}
|
|
26
|
+
const loadedMarkdownImpl = () => loadedImpl;
|
|
27
|
+
const subscribe = (onChange) => {
|
|
28
|
+
listeners.add(onChange);
|
|
29
|
+
return () => {
|
|
30
|
+
listeners.delete(onChange);
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
function useIsMarkdownClient() {
|
|
34
|
+
return useSyncExternalStore(subscribe, () => true, () => false);
|
|
35
|
+
}
|
|
36
|
+
function useMarkdownEngineSettled() {
|
|
37
|
+
return useSyncExternalStore(subscribe, () => settled, () => false);
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
loadedMarkdownImpl,
|
|
41
|
+
preloadMarkdownStream,
|
|
42
|
+
useIsMarkdownClient,
|
|
43
|
+
useMarkdownEngineSettled
|
|
44
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-matrx/chat",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.14",
|
|
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,25 +4681,25 @@
|
|
|
4681
4681
|
"server-only": "^0.0.1",
|
|
4682
4682
|
"uuid": "^14.0.2",
|
|
4683
4683
|
"zod": "^4.6.5",
|
|
4684
|
-
"@ai-matrx/
|
|
4684
|
+
"@ai-matrx/alchemy": "latest",
|
|
4685
4685
|
"@ai-matrx/browser-audio": "latest",
|
|
4686
|
-
"@ai-matrx/canvas": "latest",
|
|
4687
4686
|
"@ai-matrx/agents": "latest",
|
|
4687
|
+
"@ai-matrx/content-ir": "latest",
|
|
4688
4688
|
"@ai-matrx/content-ir-react": "latest",
|
|
4689
|
-
"@ai-matrx/
|
|
4689
|
+
"@ai-matrx/associations": "latest",
|
|
4690
4690
|
"@ai-matrx/data": "latest",
|
|
4691
4691
|
"@ai-matrx/design-system": "latest",
|
|
4692
4692
|
"@ai-matrx/icons": "latest",
|
|
4693
4693
|
"@ai-matrx/kit": "latest",
|
|
4694
|
-
"@ai-matrx/media": "latest",
|
|
4695
4694
|
"@ai-matrx/print": "latest",
|
|
4696
4695
|
"@ai-matrx/realtime": "latest",
|
|
4697
4696
|
"@ai-matrx/records": "latest",
|
|
4698
4697
|
"@ai-matrx/records-ui": "latest",
|
|
4699
4698
|
"@ai-matrx/rich-content": "latest",
|
|
4700
4699
|
"@ai-matrx/rich-editor": "latest",
|
|
4701
|
-
"@ai-matrx/
|
|
4702
|
-
"@ai-matrx/
|
|
4700
|
+
"@ai-matrx/diff": "latest",
|
|
4701
|
+
"@ai-matrx/canvas": "latest",
|
|
4702
|
+
"@ai-matrx/media": "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": "
|
|
4821
|
+
"gitHead": "0e5372354ec84ed85b1779e1d99b6922a20f1798"
|
|
4822
4822
|
}
|