@ai-matrx/chat 0.3.2 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.3.3 — 2026-10-07
4
+ ### Re-sync before matrx-frontend consumes the published package (P27)
5
+
6
+ - Carries every
7
+ matrx-frontend `packages/chat` edit after `7f630d97d9`: a server-ended failed request is a failed turn
8
+ (`thunks/terminal-request-failure`, process-stream, messages selectors), agent autosave recovery.
9
+ Its suite needs the app's content-ir host, so it lives app-side with the other host suites.
10
+ - Consumer action: none.
11
+
3
12
  ## 0.3.2
4
13
 
5
14
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -90,9 +90,14 @@ function useAgentAutoSave(agentId) {
90
90
  try {
91
91
  const snapshot = { _dirty: true };
92
92
  if (record._dirtyFields) {
93
- for (const field of Object.keys(
94
- record._dirtyFields
95
- )) {
93
+ const fields = new Set(
94
+ Object.keys(record._dirtyFields)
95
+ );
96
+ if (fields.has("modelId") || fields.has("settings")) {
97
+ fields.add("modelId");
98
+ fields.add("settings");
99
+ }
100
+ for (const field of fields) {
96
101
  snapshot[field] = readField(record, field);
97
102
  }
98
103
  }
@@ -169,6 +169,16 @@ export declare function extractInspectableText(record: MessageRecord | undefined
169
169
  * CONVERSATION_FAILURE_AND_RETRY_FE_GUIDE.md.
170
170
  */
171
171
  export declare function isFailedRecord(record: MessageRecord | undefined): boolean;
172
+ /**
173
+ * The provider stop reason a reloaded turn was saved with, when it is NOT a
174
+ * normal finish — or null. A turn the provider stopped early can be saved as
175
+ * an ordinary `active` message with its partial text and only
176
+ * `metadata.finish_reason` telling the truth (a recitation stop, 2026-10-06);
177
+ * that row reloaded with no error at all.
178
+ */
179
+ export declare function abnormalFinishReason(record: MessageRecord | undefined): string | null;
180
+ /** The sentence a reloaded early-stopped turn shows. */
181
+ export declare function earlyStopMessage(reason: string): string;
172
182
  /**
173
183
  * The persisted error text for a failed turn. Prefers the structured top-level
174
184
  * `error.message`; falls back to the legacy `metadata.error` string, then to
@@ -282,7 +282,33 @@ function isFailedRecord(record) {
282
282
  if (record.status === "failed") return true;
283
283
  if (record.error) return true;
284
284
  const md = record.metadata;
285
- return !!md && typeof md === "object" && !Array.isArray(md) && md.failed === true;
285
+ if (!!md && typeof md === "object" && !Array.isArray(md) && md.failed === true) {
286
+ return true;
287
+ }
288
+ return abnormalFinishReason(record) !== null;
289
+ }
290
+ const NORMAL_FINISH_REASONS = /* @__PURE__ */ new Set([
291
+ "stop",
292
+ "end_turn",
293
+ "stop_sequence",
294
+ "tool_use",
295
+ "tool_calls",
296
+ "function_call",
297
+ "pause_turn",
298
+ "completed",
299
+ "success"
300
+ ]);
301
+ function abnormalFinishReason(record) {
302
+ if (!record || record.role !== "assistant") return null;
303
+ const md = record.metadata;
304
+ if (!md || typeof md !== "object" || Array.isArray(md)) return null;
305
+ const reason = md.finish_reason;
306
+ if (typeof reason !== "string" || reason.length === 0) return null;
307
+ const normalized = reason.toLowerCase().replace(/^finishreason\./, "");
308
+ return NORMAL_FINISH_REASONS.has(normalized) ? null : normalized;
309
+ }
310
+ function earlyStopMessage(reason) {
311
+ return reason === "max_tokens" || reason === "length" ? "The response hit its length limit before it finished." : "The response was stopped early by the model provider, so it may be incomplete.";
286
312
  }
287
313
  function extractRecordError(record) {
288
314
  if (!record) return void 0;
@@ -294,6 +320,8 @@ function extractRecordError(record) {
294
320
  const err = md.error;
295
321
  if (typeof err === "string" && err.length > 0) return err;
296
322
  }
323
+ const stopped = abnormalFinishReason(record);
324
+ if (stopped) return earlyStopMessage(stopped);
297
325
  const flat = extractFlatText(record);
298
326
  return flat.length > 0 ? flat : void 0;
299
327
  }
@@ -545,6 +573,8 @@ const selectMessagesInterleavedRuns = (conversationId, messageIds) => {
545
573
  };
546
574
  export {
547
575
  EMPTY_CONVERSATION_MESSAGES,
576
+ abnormalFinishReason,
577
+ earlyStopMessage,
548
578
  extractAnswerDocumentText,
549
579
  extractContentBlocks,
550
580
  extractFlatText,
@@ -69,6 +69,10 @@ import {
69
69
  appendCitation
70
70
  } from "../active-requests/active-requests.slice.js";
71
71
  import { parseNormalizedCitation } from "../messages/message-citations.js";
72
+ import {
73
+ terminalFailureFromCompletion,
74
+ terminalFailureFromRecordUpdate
75
+ } from "./terminal-request-failure.js";
72
76
  import { confirmServerSync } from "../conversations/conversations.slice.js";
73
77
  import { receivedFsChange } from "../../../../host/ui-slots.js";
74
78
  import {
@@ -430,6 +434,17 @@ async function processStream({
430
434
  terminalTransportController.abort("post-terminal-grace");
431
435
  }, POST_TERMINAL_GRACE_MS);
432
436
  };
437
+ const failRequestFromTerminalStatus = (error) => {
438
+ if (!error) return;
439
+ if (getState().activeRequests.byRequestId[requestId]?.status === "error") {
440
+ return;
441
+ }
442
+ console.warn(
443
+ `[stream:${requestId.slice(0, 8)}] user request ended FAILED without an error event (${error.error_type}) \u2014 surfacing it as a failed turn.`
444
+ );
445
+ dispatch(setRequestStatus({ requestId, status: "error", error }));
446
+ dispatch(setInstanceStatus({ conversationId, status: "error" }));
447
+ };
433
448
  let lastTransportSeq = getState().activeRequests.byRequestId[requestId]?.lastTransportSeq ?? 0;
434
449
  let transportStreamId = getState().activeRequests.byRequestId[requestId]?.transportStreamId ?? null;
435
450
  let retainedEntry;
@@ -635,6 +650,7 @@ async function processStream({
635
650
  }
636
651
  if (d.operation === "user_request") {
637
652
  dispatch(setCompletion({ requestId, data: d }));
653
+ failRequestFromTerminalStatus(terminalFailureFromCompletion(d));
638
654
  completionStats = result;
639
655
  const totals = completionStats.total_usage?.total;
640
656
  if (totals) {
@@ -1664,6 +1680,7 @@ async function processStream({
1664
1680
  refreshUserMessageWithoutBody(d.record_id);
1665
1681
  }
1666
1682
  } else if (d.table === "user_request") {
1683
+ failRequestFromTerminalStatus(terminalFailureFromRecordUpdate(d));
1667
1684
  dispatch(
1668
1685
  patchUserRequest({
1669
1686
  id: d.record_id,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A request that ENDED FAILED without an `error` event.
3
+ *
4
+ * The server has three ways to say a user request failed, and only one of
5
+ * them is the `error` event: a terminal failure it persisted on purpose (a
6
+ * provider safety / recitation stop, a model refusal, any finish-reason
7
+ * failure) is announced as a `user_request` completion with
8
+ * `status: "failed"` and/or a `record_update` moving the `user_request` row
9
+ * to `failed`, then a normal `end`. Before this module the stream treated
10
+ * both as bookkeeping, the `end` handler marked the request `complete`, and
11
+ * the turn showed NO error — while its half-written content (a flashcard set
12
+ * cut off by a recitation stop, 2026-10-06) sat there looking finished.
13
+ *
14
+ * Pure: reads the event payload, returns the `ErrorPayload` the request
15
+ * should carry, or null when the event is not a terminal failure. It never
16
+ * matches on the message TEXT (the server is free to reword it) — only on
17
+ * the operation/table, the status, and the structured fields.
18
+ */
19
+ import type { CompletionPayload, ErrorPayload, RecordUpdatePayload } from "@ai-matrx/agents/generated/stream-events";
20
+ /** error_type when the server sent no structured one. */
21
+ export declare const TERMINAL_FAILURE_ERROR_TYPE = "request_failed";
22
+ /** Shown when the server said "failed" and nothing more. */
23
+ export declare const TERMINAL_FAILURE_FALLBACK_MESSAGE = "The response stopped before it finished.";
24
+ /** A `user_request` completion whose status is not success. */
25
+ export declare function terminalFailureFromCompletion(d: CompletionPayload): ErrorPayload | null;
26
+ /** A `record_update` moving the `user_request` row to `failed`. */
27
+ export declare function terminalFailureFromRecordUpdate(d: RecordUpdatePayload): ErrorPayload | null;
@@ -0,0 +1,56 @@
1
+ const TERMINAL_FAILURE_ERROR_TYPE = "request_failed";
2
+ const TERMINAL_FAILURE_FALLBACK_MESSAGE = "The response stopped before it finished.";
3
+ function asRecord(value) {
4
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
5
+ }
6
+ function readStructuredError(raw) {
7
+ if (typeof raw === "string") {
8
+ const trimmed = raw.trim();
9
+ if (trimmed.startsWith("{")) {
10
+ try {
11
+ return readStructuredError(JSON.parse(trimmed));
12
+ } catch {
13
+ }
14
+ }
15
+ return { errorType: null, message: trimmed || null };
16
+ }
17
+ const obj = asRecord(raw);
18
+ if (!obj) return { errorType: null, message: null };
19
+ const errorType = typeof obj.error_type === "string" ? obj.error_type : typeof obj.type === "string" ? obj.type : null;
20
+ const message = typeof obj.user_message === "string" && obj.user_message ? obj.user_message : typeof obj.message === "string" && obj.message ? obj.message : null;
21
+ return { errorType, message };
22
+ }
23
+ function buildPayload(source, fields, finishReason) {
24
+ const structured = readStructuredError(fields?.error);
25
+ const errorType = structured.errorType ?? (typeof fields?.error_type === "string" ? fields.error_type : null) ?? TERMINAL_FAILURE_ERROR_TYPE;
26
+ const message = structured.message ?? TERMINAL_FAILURE_FALLBACK_MESSAGE;
27
+ return {
28
+ error_type: errorType,
29
+ message,
30
+ user_message: message,
31
+ details: {
32
+ source,
33
+ ...finishReason ? { finish_reason: finishReason } : {},
34
+ ...typeof fields?.reason === "string" ? { reason: fields.reason } : {}
35
+ }
36
+ };
37
+ }
38
+ function terminalFailureFromCompletion(d) {
39
+ if (d.operation !== "user_request" || d.status !== "failed") return null;
40
+ const result = asRecord(d.result);
41
+ const metadata = asRecord(result?.metadata);
42
+ const finishReason = typeof result?.finish_reason === "string" ? result.finish_reason : null;
43
+ return buildPayload("completion", metadata ?? result, finishReason);
44
+ }
45
+ function terminalFailureFromRecordUpdate(d) {
46
+ if (d.table !== "user_request" || d.status !== "failed") return null;
47
+ const metadata = asRecord(d.metadata);
48
+ const finishReason = typeof metadata?.finish_reason === "string" ? metadata.finish_reason : null;
49
+ return buildPayload("record_update", metadata, finishReason);
50
+ }
51
+ export {
52
+ TERMINAL_FAILURE_ERROR_TYPE,
53
+ TERMINAL_FAILURE_FALLBACK_MESSAGE,
54
+ terminalFailureFromCompletion,
55
+ terminalFailureFromRecordUpdate
56
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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 per-module subpaths @ai-matrx/chat/<domain>/<module> over a host contract (/host, /host/react) whose only required value is a db; plus the kernel compatibility entries (., /external-store, /matrx, /react, /tools, styles.css, tokens.css) until P28.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -201,24 +201,24 @@
201
201
  "server-only": "^0.0.1",
202
202
  "uuid": "^14.0.2",
203
203
  "zod": "^4.6.5",
204
- "@ai-matrx/associations": "latest",
205
- "@ai-matrx/browser-audio": "latest",
206
204
  "@ai-matrx/agents": "latest",
205
+ "@ai-matrx/browser-audio": "latest",
206
+ "@ai-matrx/associations": "latest",
207
+ "@ai-matrx/alchemy": "latest",
207
208
  "@ai-matrx/canvas": "latest",
208
209
  "@ai-matrx/content-ir": "latest",
209
210
  "@ai-matrx/content-ir-react": "latest",
210
211
  "@ai-matrx/data": "latest",
211
- "@ai-matrx/design-system": "latest",
212
212
  "@ai-matrx/diff": "latest",
213
+ "@ai-matrx/design-system": "latest",
213
214
  "@ai-matrx/icons": "latest",
214
215
  "@ai-matrx/media": "latest",
216
+ "@ai-matrx/kit": "latest",
217
+ "@ai-matrx/print": "latest",
215
218
  "@ai-matrx/realtime": "latest",
216
- "@ai-matrx/records": "latest",
217
219
  "@ai-matrx/records-ui": "latest",
218
- "@ai-matrx/kit": "latest",
219
220
  "@ai-matrx/tap-target": "latest",
220
- "@ai-matrx/alchemy": "latest",
221
- "@ai-matrx/print": "latest"
221
+ "@ai-matrx/records": "latest"
222
222
  },
223
223
  "devDependencies": {
224
224
  "@arethetypeswrong/cli": "^0.18.5",