@jini-ai/chat 0.6.10 → 0.6.12

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/core/accepted-input.d.ts +2 -0
  3. package/dist/core/accepted-input.js +34 -3
  4. package/dist/core/assistant-client/durable-run-subscription.d.ts +4 -2
  5. package/dist/core/assistant-client/durable-run-subscription.js +36 -7
  6. package/dist/core/persistence/title.d.ts +8 -16
  7. package/dist/core/persistence/title.js +20 -63
  8. package/dist/core/question-answer.js +2 -2
  9. package/dist/core/run-activity.js +4 -0
  10. package/dist/core/run-events/entry.d.ts +1 -1
  11. package/dist/core/run-events/entry.js +2 -1
  12. package/dist/core/turn-usage.js +19 -2
  13. package/dist/react/components/Markdown.js +2 -2
  14. package/dist/react/components/McpUiSurfaceCard.js +10 -7
  15. package/dist/react/components/MessageList.hooks.js +5 -1
  16. package/dist/react/components/MessageRow.js +13 -8
  17. package/dist/react/components/MessageRow.usage.d.ts +9 -1
  18. package/dist/react/components/MessageRow.usage.js +22 -5
  19. package/dist/react/components/MidRunUserMessage.d.ts +1 -1
  20. package/dist/react/components/MidRunUserMessage.js +8 -5
  21. package/dist/react/components/ToolCallGroup.hooks.d.ts +3 -1
  22. package/dist/react/components/ToolCallGroup.hooks.js +2 -1
  23. package/dist/react/components/ToolCallGroup.js +1 -1
  24. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +3 -3
  25. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -5
  26. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +88 -27
  27. package/dist/react/features/chat-pane/rules.d.ts +5 -2
  28. package/dist/react/features/chat-pane/rules.js +8 -2
  29. package/dist/react/features/chat-pane/types.d.ts +7 -2
  30. package/dist/server/durable-runs/recover.js +59 -23
  31. package/dist/store/sql/store.d.ts +2 -2
  32. package/dist/store/sql/store.js +11 -3
  33. package/package.json +9 -9
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.6.12
2
+
3
+ ### Patch Changes
4
+
5
+ - chat: typing while a choice card waits queues the message instead of cancelling its tool call; answered choice cards keep their question and report option labels, not values; mid-run follow-ups read "Queued" until consumed; one steady thinking clock.
6
+ - @jini-ai/core@0.6.12
7
+
8
+ ## 0.6.11
9
+
10
+ ### Patch Changes
11
+
12
+ - Honest turn footers (done / stopped / interrupted), answered question cards keep their question and chosen labels, readable conversation titles and counts; the usage tooltip reads "N output tokens".
13
+ - Mid-run message relay for MCP-bridged engines (Codex, ACP, Antigravity): `registerMessageRelayRoutes` on `@jini-ai/daemon/http`; mid-run messages queue instead of interrupting, and recovery waits on an uncertain probe.
14
+ - Updated dependencies
15
+ - Updated dependencies
16
+ - @jini-ai/core@0.6.11
17
+
1
18
  ## 0.6.10
2
19
 
3
20
  ### Patch Changes
@@ -3,6 +3,8 @@ import type { ChatAttachment, ChatMessage } from './messages.js';
3
3
  export interface AcceptedChatInput {
4
4
  id: string;
5
5
  text: string;
6
+ /** Absent for legacy/ordinary inputs; consumed only after a CLI UUID receipt. */
7
+ deliveryState?: 'queued' | 'consumed';
6
8
  attachments?: ChatAttachment[];
7
9
  secretRedaction?: ChatMessage['secretRedaction'];
8
10
  /** Wire ids replaced by this receipt remain seen by recall. */
@@ -50,8 +50,16 @@ function expandQuestionAnswers({ events }, _options = {}) {
50
50
  }));
51
51
  // MOVE: Persisted picks carry values; the original tool input retains their human-readable labels.
52
52
  const questions = new Map(events.flatMap(event => event.kind === 'tool_use' ? [[event.id, event.input]] : []));
53
- return events.flatMap(event => {
53
+ const answers = new Map(events.flatMap(event => {
54
54
  const answer = questionAnswerEvent({ event, input: event.kind === 'tool_result' ? questions.get(event.toolUseId) : undefined });
55
+ return answer ? [[answer.data.toolUseId, answer]] : [];
56
+ }));
57
+ return events.flatMap(event => {
58
+ const receipt = acceptedInputEvent({ event });
59
+ const recorded = receipt?.data.toolUseId ? answers.get(receipt.data.toolUseId) : undefined;
60
+ if (receipt && recorded)
61
+ return [{ ...receipt, data: { ...receipt.data, text: recorded.data.text } }];
62
+ const answer = event.kind === 'tool_result' ? answers.get(event.toolUseId) : undefined;
55
63
  return answer && !savedAnswers.has(answer.data.toolUseId) ? [event, answer] : [event];
56
64
  });
57
65
  }
@@ -81,7 +89,7 @@ function matchOptimisticInput({ input, optimistic, seen }, _options = {}) {
81
89
  }
82
90
  function mergeAcceptedReceipt({ input, pending }, _options = {}) {
83
91
  return { ...input, data: { ...input.data, id: pending.data.id,
84
- ...(pending.data.inputs ? { inputs: pending.data.inputs } : {}),
92
+ ...(pending.data.inputs ? { inputs: pending.data.inputs.map(item => ({ ...item, ...(input.data.deliveryState ? { deliveryState: input.data.deliveryState } : {}) })) } : {}),
85
93
  aliases: [...new Set([...(input.data.aliases ?? []), input.data.id, ...(pending.data.aliases ?? [])])] } };
86
94
  }
87
95
  function acceptedInputIds({ input }, _options = {}) {
@@ -114,13 +122,36 @@ function advanceOptimisticInput({ input, optimistic }, _options = {}) {
114
122
  if (candidates && !input.data.toolUseId)
115
123
  candidates.index += 1;
116
124
  }
125
+ /** Fold state updates into the original bubble, preserving its position, local id and aliases.
126
+ * O(e + a + i) time/space for events, receipt aliases and expanded input constituents; equal text never establishes identity. */
127
+ function collapseDeliveryReceipts({ events }, _options = {}) {
128
+ const result = [];
129
+ const positions = new Map();
130
+ for (const event of events) {
131
+ const input = acceptedInputEvent({ event });
132
+ const position = input ? positions.get(input.data.id) : undefined;
133
+ if (input && position !== undefined && input.data.deliveryState) {
134
+ const original = acceptedInputEvent({ event: result[position] });
135
+ // Replayed queue acceptance cannot undo confirmed consumption.
136
+ const deliveryState = original.data.deliveryState === 'consumed' ? 'consumed' : input.data.deliveryState;
137
+ result[position] = { ...original, data: { ...original.data, deliveryState,
138
+ ...(original.data.inputs ? { inputs: original.data.inputs.map(input => ({ ...input, deliveryState })) } : {}) } };
139
+ continue;
140
+ }
141
+ if (input)
142
+ for (const id of acceptedInputIds({ input }))
143
+ positions.set(id, result.length);
144
+ result.push(event);
145
+ }
146
+ return result;
147
+ }
117
148
  /** Projects saved answers and reconciles optimistic bubbles with wire events, occurrence for
118
149
  * occurrence. Legacy steering receipts have no client id: only unmatched optimistic input may
119
150
  * match by text; two real events with equal text always remain distinct.
120
151
  * @complexity O(e + u + a) time, O(e + u) space for e events, u user inputs and total answer-label lookup cost a.
121
152
  */
122
153
  export function projectAcceptedInputEvents({ events }, _options = {}) {
123
- const expanded = expandQuestionAnswers({ events });
154
+ const expanded = expandQuestionAnswers({ events: collapseDeliveryReceipts({ events }) });
124
155
  const optimistic = collectOptimisticInputs({ events: expanded });
125
156
  const state = { replacements: new Map(), replaced: new Set(), seen: new Set() };
126
157
  for (const event of expanded)
@@ -17,7 +17,7 @@ export interface DurableSubscriptionPorts {
17
17
  runId: string;
18
18
  cursor: string;
19
19
  frame: (kind: string, data: string, cursor: string) => void;
20
- dropped: () => void;
20
+ dropped: (reason?: string) => void;
21
21
  }, optional: {}) => () => void;
22
22
  readonly schedule: (required: {
23
23
  work: () => void;
@@ -37,6 +37,8 @@ export declare function followDurableRun({ runId: initialRunId, handlers, signal
37
37
  ports: DurableSubscriptionPorts;
38
38
  notices: RunNotices;
39
39
  bindings: Map<string, DurableRunBinding>;
40
- }, _optional?: {}): () => void;
40
+ }, { logger }?: {
41
+ logger?: Pick<Console, "info">;
42
+ }): () => void;
41
43
  export {};
42
44
  //# sourceMappingURL=durable-run-subscription.d.ts.map
@@ -1,18 +1,21 @@
1
1
  import { translateRunFrame } from "../run-events/entry.js";
2
2
  /** SSE is a view of one attempt. The saved message owns terminal state and can point at a new
3
3
  * attempt after recovery; neither a browser 404 nor a closed stream may fail the logical run. */
4
- export function followDurableRun({ runId: initialRunId, handlers, signal, binding, ports, notices, bindings }, _optional = {}) {
4
+ export function followDurableRun({ runId: initialRunId, handlers, signal, binding, ports, notices, bindings }, { logger = console } = {}) {
5
+ let streamEpoch = 0;
5
6
  let runId = initialRunId;
6
7
  let cursor = "";
7
8
  let stopped = false;
8
9
  let reading = false;
9
10
  let recoveryPending = true;
10
11
  let dropped = false;
12
+ let reconnectDelayMs = 0;
11
13
  let stopStream;
12
14
  let stopTimer;
13
15
  let messageId = binding?.messageId;
14
16
  function stop() {
15
17
  stopped = true;
18
+ streamEpoch += 1;
16
19
  stopStream?.();
17
20
  stopTimer?.();
18
21
  signal?.removeEventListener("abort", stop);
@@ -25,15 +28,42 @@ export function followDurableRun({ runId: initialRunId, handlers, signal, bindin
25
28
  handlers.onDone(message.events ?? []);
26
29
  }
27
30
  function open() {
31
+ const epoch = ++streamEpoch;
28
32
  dropped = false;
29
33
  stopStream?.();
30
- stopStream = ports.stream({ runId, cursor, frame, dropped: () => { dropped = true; recoveryPending = true; void read().catch(() => undefined); } }, {});
34
+ stopStream = ports.stream({ runId, cursor,
35
+ frame: (...args) => { if (epoch === streamEpoch)
36
+ frame(...args); },
37
+ dropped: reason => { if (epoch === streamEpoch)
38
+ disconnected(reason); },
39
+ }, {});
40
+ }
41
+ /** One timer owns reconnects, including faults during a checkpoint read. No cursor reset. */
42
+ function disconnected(reason = "stream-error") {
43
+ if (stopped || dropped)
44
+ return;
45
+ dropped = true;
46
+ streamEpoch += 1;
47
+ recoveryPending = true;
48
+ reconnectDelayMs = Math.min(30_000, Math.max(1_000, reconnectDelayMs * 2));
49
+ // Metadata only: neither frame contents nor saved user messages belong in transport logs.
50
+ logger.info("[durable-subscription] " + JSON.stringify({ runId, reason, cursor, retryDelayMs: reconnectDelayMs }));
51
+ stopStream?.();
52
+ if (!reading)
53
+ scheduleRead();
54
+ }
55
+ function scheduleRead() {
56
+ stopTimer?.();
57
+ stopTimer = ports.schedule({ work: () => void read().catch(() => undefined), delayMs: dropped ? reconnectDelayMs : 1_000 }, {});
31
58
  }
32
59
  function frame(kind, data, nextCursor) {
33
60
  if (stopped)
34
61
  return;
35
- if (nextCursor)
62
+ if (nextCursor) {
63
+ if (nextCursor !== cursor)
64
+ reconnectDelayMs = 0;
36
65
  cursor = nextCursor;
66
+ }
37
67
  const outcome = translateRunFrame({ kind, raw: data, notices }, {});
38
68
  // An attempt's error/end may be followed by a continuation. Only the row can finalize it.
39
69
  if (outcome.terminal || outcome.error) {
@@ -70,10 +100,8 @@ export function followDurableRun({ runId: initialRunId, handlers, signal, bindin
70
100
  finally {
71
101
  reading = false;
72
102
  if (!stopped) {
73
- if (recoveryPending)
74
- void read().catch(() => undefined);
75
- else
76
- stopTimer = ports.schedule({ work: () => void read().catch(() => undefined), delayMs: 1_000 }, {});
103
+ // Always schedule: an error arriving during read must not create a tight recovery loop.
104
+ scheduleRead();
77
105
  }
78
106
  }
79
107
  }
@@ -87,6 +115,7 @@ export function followDurableRun({ runId: initialRunId, handlers, signal, bindin
87
115
  if (message.runId && message.runId !== runId) {
88
116
  runId = message.runId;
89
117
  cursor = "";
118
+ reconnectDelayMs = 0;
90
119
  if (binding)
91
120
  bindings.set(runId, binding);
92
121
  dropped = true;
@@ -10,18 +10,12 @@
10
10
  * better title asks its agent for one later and calls `rename(id, title, 'generated')`; the
11
11
  * store refuses to overwrite a `manual` title, so a user's own rename always wins.
12
12
  *
13
- * Ported from Open Design's `summarizeProjectNameFromPrompt` (`apps/web/src/utils/projectName.ts`),
14
- * which this generalizes from project names to conversation titles. Its behaviour is preserved
15
- * deliberately — including the CJK path, which is not decoration: the filler and punctuation
16
- * rules for Chinese prompts differ enough from the Latin ones that a shared code path produced
17
- * visibly worse titles in both.
18
- *
19
- * Two intentional divergences from the original, both fixing output the original gets wrong; each
20
- * is documented at its own declaration below:
21
- * 1. `LEADING_LATIN_FILLER` requires the separator inside the article group. The original's form
22
- * let a bare `a` eat the first letter of the next word ("update app.tsx" → "Pp Tsx").
23
- * 2. `LATIN_NEVER_TRAILING` trims function words off the end. The original's word cap lands
24
- * mid-clause on question-shaped prompts and strands them ("...Posts Do I").
13
+ * Ported from Open Design's `summarizeProjectNameFromPrompt` (`apps/web/src/utils/projectName.ts`).
14
+ * The Latin path has since diverged: it keeps the prompt's words, names and dotted domains (the
15
+ * original's stop-word removal and title casing turned "port example.com to jini" into "Port
16
+ * Example Com Jini"), and `LEADING_LATIN_FILLER` fixes the original's article bug, documented at
17
+ * its declaration. The CJK path keeps the original's filler and punctuation rules, which is not
18
+ * decoration: they differ enough from the Latin ones that a shared path produced worse titles in both.
25
19
  */
26
20
  /** Remove presentation markup from title text while preserving link labels and inline code.
27
21
  * Shared by prompt derivation, stored-title display and product naming adapters; no DOM or I/O.
@@ -45,10 +39,8 @@ export declare function stripConversationTitleMarkdown({ text }: {
45
39
  */
46
40
  export declare function deriveConversationTitle({ prompt }: {
47
41
  prompt: string;
48
- }, { casing }?: {
49
- casing?: 'title' | 'sentence';
50
- }): string;
51
- /** Sentence-case only a known heuristic title using the original prompt's proper names/acronyms.
42
+ }, _optional?: Record<string, never>): string;
43
+ /** A known heuristic title is already display-ready: derivation keeps the prompt's own wording.
52
44
  * Manual names, model-authored names and unloaded transcripts keep their saved wording, with
53
45
  * presentation markup removed and the display ceiling applied; no writes.
54
46
  * @complexity O(n) time/space in the first prompt's length, using the existing bounded heuristic.
@@ -10,26 +10,19 @@
10
10
  * better title asks its agent for one later and calls `rename(id, title, 'generated')`; the
11
11
  * store refuses to overwrite a `manual` title, so a user's own rename always wins.
12
12
  *
13
- * Ported from Open Design's `summarizeProjectNameFromPrompt` (`apps/web/src/utils/projectName.ts`),
14
- * which this generalizes from project names to conversation titles. Its behaviour is preserved
15
- * deliberately — including the CJK path, which is not decoration: the filler and punctuation
16
- * rules for Chinese prompts differ enough from the Latin ones that a shared code path produced
17
- * visibly worse titles in both.
18
- *
19
- * Two intentional divergences from the original, both fixing output the original gets wrong; each
20
- * is documented at its own declaration below:
21
- * 1. `LEADING_LATIN_FILLER` requires the separator inside the article group. The original's form
22
- * let a bare `a` eat the first letter of the next word ("update app.tsx" → "Pp Tsx").
23
- * 2. `LATIN_NEVER_TRAILING` trims function words off the end. The original's word cap lands
24
- * mid-clause on question-shaped prompts and strands them ("...Posts Do I").
13
+ * Ported from Open Design's `summarizeProjectNameFromPrompt` (`apps/web/src/utils/projectName.ts`).
14
+ * The Latin path has since diverged: it keeps the prompt's words, names and dotted domains (the
15
+ * original's stop-word removal and title casing turned "port example.com to jini" into "Port
16
+ * Example Com Jini"), and `LEADING_LATIN_FILLER` fixes the original's article bug, documented at
17
+ * its declaration. The CJK path keeps the original's filler and punctuation rules, which is not
18
+ * decoration: they differ enough from the Latin ones that a shared path produced worse titles in both.
25
19
  */
26
- const MAX_LATIN_WORDS = 6;
27
20
  const MAX_CJK_LENGTH = 18;
28
21
  /**
29
22
  * Hard character ceiling, because a word cap is not a length cap.
30
23
  *
31
- * Six "words" says nothing about their size: a pasted base64 blob or minified bundle is a single
32
- * enormous token, so it survived the word cap intact and became the title verbatim. That title is
24
+ * A word boundary says nothing about token size: a pasted base64 blob or minified bundle is a single
25
+ * enormous token, so it must not survive intact and become the title verbatim. That title is
33
26
  * then stored, listed, and re-rendered on every conversation-list read — megabytes of it. Applied to
34
27
  * the finished title so both the Latin and CJK paths are covered.
35
28
  */
@@ -51,33 +44,9 @@ const LEADING_CJK_FILLER = [
51
44
  /^(先)?(实现|做|做一下|创建|生成|设计|开发|新增|添加|优化|修复|改|更改|调整)(一下|一个|一版|下)?/,
52
45
  /^(一个|一份|这个|那个)/,
53
46
  ];
54
- const LATIN_STOP_WORDS = new Set([
55
- 'a', 'an', 'and', 'for', 'in', 'of', 'on', 'please', 'the', 'to', 'with', 'my',
56
- ]);
57
- /**
58
- * Function words that are fine *inside* a title but never acceptable as its last word.
59
- *
60
- * These cannot go in `LATIN_STOP_WORDS`, because removing them everywhere mangles real titles —
61
- * "how many posts do I have" would lose the "how many" that makes it a question, and a prompt like
62
- * "make it work" would reduce to "Work". The problem is specifically the word-cap boundary: a
63
- * question-shaped prompt reliably spends its six words before reaching the noun, so the title got
64
- * cut mid-clause. Observed live: "how many published posts do I have?" produced
65
- * "How Many Published Posts Do I" — a dangling auxiliary plus a stranded pronoun, which in a narrow
66
- * dock also wrapped to leave a lone "I" on its own line.
67
- *
68
- * Trimming only the tail is what makes this safe: it can never shorten a title that already ended
69
- * on a content word, so no existing good title changes.
70
- */
71
- const LATIN_NEVER_TRAILING = new Set([
72
- 'do', 'does', 'did', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
73
- 'have', 'has', 'had', 'can', 'could', 'should', 'would', 'will', 'shall', 'may', 'might', 'must',
74
- 'i', 'me', 'you', 'we', 'us', 'it', 'they', 'them', 'he', 'she',
75
- 'that', 'this', 'these', 'those', 'but', 'or', 'if', 'then', 'than', 'as', 'at', 'by',
76
- 'from', 'into', 'about', 'so', 'just', 'also', 'too', 'up', 'out',
77
- ]);
78
47
  /**
79
48
  * Strips the parts of a prompt that are never a good title: fenced code, URLs, and
80
- * `@handle`/`#tag` tokens. Each of these is high-entropy and low-meaning in a six-word summary.
49
+ * `@handle`/`#tag` tokens. Each of these is high-entropy and low-meaning in a short summary.
81
50
  * Inline code keeps its label with delimiters removed, so a filename can still name the task.
82
51
  */
83
52
  function cleanPrompt(prompt) {
@@ -114,7 +83,8 @@ export function stripConversationTitleMarkdown({ text }, _options = {}) {
114
83
  .trim();
115
84
  }
116
85
  /** Keep complete words at the display ceiling. A single oversized token still gets a bounded
117
- * prefix: pasted blobs must never become megabyte-sized stored/listed titles.
86
+ * prefix: pasted blobs must never become megabyte-sized stored/listed titles. The old six-word cap
87
+ * stranded auxiliaries and pronouns mid-clause; the character ceiling leaves short questions whole.
118
88
  * @complexity O(n) time/space, bounded by the title ceiling after the initial length check.
119
89
  */
120
90
  function truncateTitle(title) {
@@ -126,24 +96,11 @@ function truncateTitle(title) {
126
96
  const lastSpace = prefix.lastIndexOf(' ');
127
97
  return (lastSpace > 0 ? prefix.slice(0, lastSpace) : prefix).trimEnd();
128
98
  }
129
- /** Callers pass only non-empty words (`trimLatinTitle` filters blanks before mapping). */
130
- function toTitleCase(word) {
131
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
132
- }
133
- function trimLatinTitle(input, { casing }) {
134
- const words = input
135
- .replace(LEADING_LATIN_FILLER, '')
136
- .replace(/[^\p{L}\p{N}\s-]/gu, ' ')
137
- .split(/\s+/)
138
- .filter(Boolean)
139
- .filter((word) => !LATIN_STOP_WORDS.has(word.toLowerCase()))
140
- .slice(0, MAX_LATIN_WORDS);
141
- // Applied after the cap, because the cap is what strands these words in the first place.
142
- while (words.length > 0 && LATIN_NEVER_TRAILING.has(words[words.length - 1].toLowerCase())) {
143
- words.pop();
144
- }
145
- const title = casing === 'title' ? words.map(toTitleCase).join(' ') : words.join(' ');
146
- return title ? title.charAt(0).toUpperCase() + title.slice(1) : title;
99
+ function trimLatinTitle(input) {
100
+ const words = input.replace(LEADING_LATIN_FILLER, '').match(/[\p{L}\p{N}]+(?:[._-][\p{L}\p{N}]+)*/gu) ?? [];
101
+ const title = words.join(' ');
102
+ // Domain and filename tokens retain their original case, even at the start of a sentence.
103
+ return words[0]?.includes('.') ? title : title.charAt(0).toUpperCase() + title.slice(1);
147
104
  }
148
105
  function trimCjkTitle(input) {
149
106
  let title = input.trim();
@@ -161,7 +118,7 @@ function trimCjkTitle(input) {
161
118
  *
162
119
  * @complexity O(n) in prompt length; no allocation beyond the intermediate strings.
163
120
  */
164
- export function deriveConversationTitle({ prompt }, { casing = 'title' } = {}) {
121
+ export function deriveConversationTitle({ prompt }, _optional = {}) {
165
122
  const cleaned = cleanPrompt(prompt);
166
123
  if (!cleaned)
167
124
  return '';
@@ -169,12 +126,12 @@ export function deriveConversationTitle({ prompt }, { casing = 'title' } = {}) {
169
126
  const firstClause = cleaned.split(/[\n\r。!?!?]/)[0].trim();
170
127
  if (!firstClause)
171
128
  return '';
172
- const title = CJK_PATTERN.test(firstClause) ? trimCjkTitle(firstClause) : trimLatinTitle(firstClause, { casing });
129
+ const title = CJK_PATTERN.test(firstClause) ? trimCjkTitle(firstClause) : trimLatinTitle(firstClause);
173
130
  // Truncated without an ellipsis: this is a working title, not prose, and a `…` would end up stored
174
131
  // and shown as if the author wrote it.
175
132
  return truncateTitle(title);
176
133
  }
177
- /** Sentence-case only a known heuristic title using the original prompt's proper names/acronyms.
134
+ /** A known heuristic title is already display-ready: derivation keeps the prompt's own wording.
178
135
  * Manual names, model-authored names and unloaded transcripts keep their saved wording, with
179
136
  * presentation markup removed and the display ceiling applied; no writes.
180
137
  * @complexity O(n) time/space in the first prompt's length, using the existing bounded heuristic.
@@ -183,7 +140,7 @@ export function conversationTitleForDisplay({ title, titleSource, firstPrompt },
183
140
  if (!title)
184
141
  return title;
185
142
  if (titleSource === 'generated' && firstPrompt && title === deriveConversationTitle({ prompt: firstPrompt })) {
186
- return deriveConversationTitle({ prompt: firstPrompt }, { casing: 'sentence' });
143
+ return title;
187
144
  }
188
145
  return truncateTitle(stripConversationTitleMarkdown({ text: title })) || null;
189
146
  }
@@ -15,11 +15,11 @@ function selectedAnswerText({ result, input }, _options = {}) {
15
15
  const question = record(input);
16
16
  const labels = [];
17
17
  if (typeof result.choice === 'string')
18
- labels.push(optionLabel(question.singleSelect, result.choice));
18
+ labels.push(optionLabel(question.singleSelect ?? question, result.choice));
19
19
  if (Array.isArray(result.selections)) {
20
20
  for (const selection of result.selections) {
21
21
  if (typeof selection === 'string')
22
- labels.push(optionLabel(question.multiSelect, selection));
22
+ labels.push(optionLabel(question.multiSelect ?? question, selection));
23
23
  }
24
24
  }
25
25
  if (labels.length > 0)
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { a2uiSurfaceIdOf, displayOnlySurfaceIdOf } from '@jini-ai/agentic/a2ui';
21
21
  import { isDelegatedWrapperToolName } from './tool-events.js';
22
+ import { MID_RUN_USER_MESSAGE_EVENT_NAME } from './turn-usage.js';
22
23
  /** Tool names whose only job is finding the right tool (MCP tool search / schema lookup). */
23
24
  const DISCOVERY_TOOL_NAMES = new Set([
24
25
  'ToolSearch',
@@ -88,6 +89,9 @@ function scanStatus({ scan, event, index }, _optional = {}) {
88
89
  }
89
90
  }
90
91
  function scanEvent(scan, event, index) {
92
+ // Queue acceptance/consumption is user input, not new agent activity. Neither restarts its clock.
93
+ if (event.kind === 'ext' && event.name === MID_RUN_USER_MESSAGE_EVENT_NAME)
94
+ return;
91
95
  if (isVisible(event)) {
92
96
  scan.lastVisible = { index, kind: event.kind };
93
97
  scan.visibleCount += 1;
@@ -53,7 +53,7 @@ export declare function asString({ value }: {
53
53
  * `mcp-ui`/`a2ui` cases (each a one-line unwrap, zero branching — see their own comments below) for
54
54
  * a cost that actually came from here.
55
55
  */
56
- /** Keep only numeric usage fields; omit absent fields without changing JSON output. */
56
+ /** Keep numeric usage and the model's stop reason; omit absent or malformed fields. */
57
57
  export declare function parseUsageEvent({ payload }: {
58
58
  payload: RunAgentPayload;
59
59
  }, _options?: Record<string, never>): AgentEvent;
@@ -25,7 +25,7 @@ export function asString({ value }, _options = {}) {
25
25
  * `mcp-ui`/`a2ui` cases (each a one-line unwrap, zero branching — see their own comments below) for
26
26
  * a cost that actually came from here.
27
27
  */
28
- /** Keep only numeric usage fields; omit absent fields without changing JSON output. */
28
+ /** Keep numeric usage and the model's stop reason; omit absent or malformed fields. */
29
29
  export function parseUsageEvent({ payload }, _options = {}) {
30
30
  const usage = (payload.usage ?? {});
31
31
  return {
@@ -36,6 +36,7 @@ export function parseUsageEvent({ payload }, _options = {}) {
36
36
  ...(typeof payload.cumulativeCostUsd === "number" ? { cumulativeCostUsd: payload.cumulativeCostUsd } : {}),
37
37
  ...(typeof payload.costSessionId === "string" ? { costSessionId: payload.costSessionId } : {}),
38
38
  ...(typeof payload.durationMs === "number" ? { durationMs: payload.durationMs } : {}),
39
+ ...(typeof payload.stopReason === "string" ? { stopReason: payload.stopReason } : {}),
39
40
  };
40
41
  }
41
42
  /**
@@ -5,6 +5,18 @@ export const MID_RUN_USER_MESSAGE_EVENT_NAME = 'user_message';
5
5
  export function isReplyBoundary({ event }, _optional = {}) {
6
6
  return event.kind === 'ext' && event.name === MID_RUN_USER_MESSAGE_EVENT_NAME;
7
7
  }
8
+ /** Queued/consumed receipts update one input, so usage must split that reply only once. */
9
+ function newReplyBoundary({ event, seen }, _options = {}) {
10
+ if (!isReplyBoundary({ event }))
11
+ return false;
12
+ const id = event.data;
13
+ if (typeof id?.id !== 'string')
14
+ return true;
15
+ if (seen.has(id.id))
16
+ return false;
17
+ seen.add(id.id);
18
+ return true;
19
+ }
8
20
  /** Split one assistant message's events into replies at each mid-run user message. A usage event
9
21
  * that lands after a boundary but before the next reply produced anything is the interrupted
10
22
  * reply's own result (the daemon notes it can arrive after the interrupt ack), so it stays with
@@ -13,8 +25,9 @@ export function isReplyBoundary({ event }, _optional = {}) {
13
25
  export function replySegments({ events }, _optional = {}) {
14
26
  const segments = [[]];
15
27
  let started = false;
28
+ const seen = new Set();
16
29
  for (const event of events ?? []) {
17
- if (isReplyBoundary({ event })) {
30
+ if (newReplyBoundary({ event, seen })) {
18
31
  segments.push([]);
19
32
  started = false;
20
33
  continue;
@@ -27,7 +40,7 @@ export function replySegments({ events }, _optional = {}) {
27
40
  }
28
41
  /** Usage, status and raw lines are bookkeeping; anything else means the reply has produced output. */
29
42
  function producesReplyOutput({ event }, _optional = {}) {
30
- return event.kind !== 'usage' && event.kind !== 'status' && event.kind !== 'raw';
43
+ return !['usage', 'status', 'raw'].includes(event.kind) && !isReplyBoundary({ event });
31
44
  }
32
45
  /** Resolve one charge against this agent/session's last snapshot. State is local to an
33
46
  * ordered transcript projection; revisiting a session keeps its baseline. A lower snapshot
@@ -61,6 +74,8 @@ export function turnUsage({ events }, { sessions = new Map(), agentId = '' } = {
61
74
  continue;
62
75
  total ??= { kind: 'usage' };
63
76
  addReportedCounts({ total, event });
77
+ if (event.stopReason !== undefined)
78
+ total.stopReason = event.stopReason;
64
79
  const cost = usageCost({ event, agentId, sessions });
65
80
  if (cost !== undefined)
66
81
  total.costUsd = (total.costUsd ?? 0) + cost;
@@ -80,6 +95,8 @@ function addUsage({ total, usage }, _optional = {}) {
80
95
  if (!usage)
81
96
  return total;
82
97
  const next = { ...(total ?? { kind: 'usage' }) };
98
+ if (usage.stopReason !== undefined)
99
+ next.stopReason = usage.stopReason;
83
100
  for (const field of ['inputTokens', 'outputTokens', 'durationMs', 'costUsd']) {
84
101
  const value = usage[field];
85
102
  if (value !== undefined)
@@ -428,9 +428,9 @@ function renderInline(text) {
428
428
  else if (mdLink)
429
429
  nodes.push(renderMarkdownLink(mdLink, key++));
430
430
  else if (bold)
431
- nodes.push(_jsx("strong", { children: bold.slice(2, -2) }, key++));
431
+ nodes.push(_jsx("strong", { children: renderInline(bold.slice(2, -2)) }, key++));
432
432
  else if (italic)
433
- nodes.push(_jsx("em", { children: italic.slice(1, -1) }, key++));
433
+ nodes.push(_jsx("em", { children: renderInline(italic.slice(1, -1)) }, key++));
434
434
  else if (link)
435
435
  nodes.push(_jsx("a", { href: link, ...NEW_TAB, children: link }, key++));
436
436
  // `whole` is `match[0]` — a successful `RegExpExecArray` always has a
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * @module components/McpUiSurfaceCard
4
4
  *
@@ -120,8 +120,10 @@ function PendingSurfaceMirror({ uri, plan, t }) {
120
120
  }
121
121
  /** A closed card: the question's title, when the call gave one, and why it is closed. */
122
122
  function ClosedSurface({ call, label }) {
123
- const title = call?.input?.title;
124
- return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' && title.trim() !== '' ? `${title}: ${label}` : label }));
123
+ const input = call?.input;
124
+ const title = [input?.title, input?.question, input?.prompt]
125
+ .find(value => typeof value === 'string' && value.trim() !== '');
126
+ return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' ? `${title}: ${label}` : label }));
125
127
  }
126
128
  /** The live card's answer lifecycle: its deadline and delivery notification, using their existing owners. */
127
129
  function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnswerText, call }, _options = {}) {
@@ -144,16 +146,17 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
144
146
  const closed = single === undefined
145
147
  ? undefined
146
148
  : closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
147
- const answer = answerText ?? questionAnswerFromResult({ result: call?.result, input: call?.input });
149
+ const answer = questionAnswerFromResult({ result: call?.result, input: call?.input }) ?? answerText;
148
150
  return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: call, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
149
151
  }
150
152
  /** Rendering priority stays receipt, answer, closure, then the live documents. */
151
153
  function SurfaceCardContent({ resources, single, answerRecorded, answer, closed, call, t, sandboxProxyUrl, toolCall, onOpenLink, maxHeight, expiry }) {
152
- // MOVE: The accepted-input receipt owns its bubble; rendering the closed card too duplicates typed answers.
154
+ // MOVE: The accepted-input receipt owns its bubble; the closed card retains the question's context.
153
155
  if (single !== undefined && answerRecorded)
154
- return null;
156
+ return _jsx(ClosedSurface, { call: call, label: answer ?? closed ?? t('Answered') });
155
157
  if (single !== undefined && answer !== undefined) {
156
- return _jsx(MidRunUserMessage, { events: [{ text: answer === 'Declined' ? t('Declined') : answer }] });
158
+ const label = answer === 'Declined' ? t('Declined') : answer;
159
+ return _jsxs(_Fragment, { children: [_jsx(ClosedSurface, { call: call, label: label }), _jsx(MidRunUserMessage, { events: [{ text: label }] })] });
157
160
  }
158
161
  if (closed !== undefined)
159
162
  return _jsx(ClosedSurface, { call: call, label: closed });
@@ -6,11 +6,15 @@
6
6
  */
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
  import { conversationUsage } from '../../core/turn-usage.js';
9
+ import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
9
10
  import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
10
11
  const prefersReducedMotion = () => typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
11
12
  /** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
12
13
  export function useConversationUsage({ messages }, _optional = {}) {
13
- return useMemo(() => conversationUsage({ messages }), [messages]);
14
+ // Usage and rendering must see the same answer boundaries, including receipts rebuilt on reload.
15
+ return useMemo(() => conversationUsage({ messages: messages.map(message => message.events === undefined ? message : {
16
+ ...message, events: projectAcceptedInputEvents({ events: message.events }),
17
+ }) }), [messages]);
14
18
  }
15
19
  export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled, reducedMotion = prefersReducedMotion } = {}) {
16
20
  const containerRef = useRef(null);