@jini-ai/chat 0.6.11 → 0.6.15-rc.1

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 (55) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/contracts/chat-bubble-style.d.ts +3 -2
  3. package/dist/contracts/chat-bubble-style.js +3 -2
  4. package/dist/core/accepted-input.d.ts +20 -1
  5. package/dist/core/accepted-input.js +65 -17
  6. package/dist/core/ag-ui/entry.d.ts +2 -4
  7. package/dist/core/ag-ui/entry.js +11 -5
  8. package/dist/core/assistant-client/ag-ui-transport.js +6 -1
  9. package/dist/core/assistant-client/durable-run-subscription.d.ts +2 -1
  10. package/dist/core/assistant-client/durable-run-subscription.js +3 -1
  11. package/dist/core/assistant-client/transport.js +5 -1
  12. package/dist/core/assistant-content.d.ts +8 -1
  13. package/dist/core/assistant-content.js +17 -4
  14. package/dist/core/durable-runs/continuation.js +6 -2
  15. package/dist/core/events.d.ts +3 -0
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.js +1 -0
  18. package/dist/core/question-answer.d.ts +8 -0
  19. package/dist/core/question-answer.js +16 -4
  20. package/dist/core/run-activity.d.ts +1 -1
  21. package/dist/core/run-activity.js +50 -4
  22. package/dist/core/run-events/entry.js +5 -2
  23. package/dist/embed-widget/vanilla/style.css +1 -1
  24. package/dist/react/components/CopyMessageButton.d.ts +18 -0
  25. package/dist/react/components/CopyMessageButton.js +22 -0
  26. package/dist/react/components/Markdown.js +24 -5
  27. package/dist/react/components/McpUiSurfaceCard.js +50 -17
  28. package/dist/react/components/MessageList.hooks.d.ts +2 -1
  29. package/dist/react/components/MessageList.hooks.js +14 -4
  30. package/dist/react/components/MessageList.js +5 -3
  31. package/dist/react/components/MessageRow.d.ts +3 -1
  32. package/dist/react/components/MessageRow.js +66 -38
  33. package/dist/react/components/MidRunUserMessage.d.ts +1 -1
  34. package/dist/react/components/MidRunUserMessage.js +14 -7
  35. package/dist/react/components/QuestionForm.js +22 -2
  36. package/dist/react/components/ToolCard.js +21 -8
  37. package/dist/react/components/mcp-ui-question-input.d.ts +20 -0
  38. package/dist/react/components/mcp-ui-question-input.js +72 -0
  39. package/dist/react/ext-event-renderer-registry.d.ts +2 -0
  40. package/dist/react/features/chat-pane/components/ChatPane.js +10 -3
  41. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +6 -0
  42. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +89 -25
  43. package/dist/react/features/chat-pane/rules.d.ts +5 -2
  44. package/dist/react/features/chat-pane/rules.js +8 -2
  45. package/dist/react/hooks/useAnsweredCards.js +36 -20
  46. package/dist/react/hooks/useConversation.d.ts +6 -0
  47. package/dist/react/hooks/useConversation.js +10 -3
  48. package/dist/react/index.d.ts +7 -0
  49. package/dist/react/index.js +4 -0
  50. package/dist/react/message-blocks.d.ts +6 -0
  51. package/dist/react/message-blocks.js +27 -1
  52. package/dist/server/durable-runs/accept.d.ts +4 -0
  53. package/dist/server/durable-runs/accept.js +8 -1
  54. package/dist/server/durable-runs/recover.js +5 -4
  55. package/package.json +15 -15
package/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## 0.6.13
2
+
3
+ ### Patch Changes
4
+
5
+ - 3feabff: Keep standalone MCP-UI questions answered across history replay through resource-bound accepted-input receipts. Preserve readable question and option labels, accessible frame titles, and waiting activity until an answer is recorded.
6
+ - e5f4969: Chat transcript fixes: answered approval/option cards show "<title>: <option label>" instead of raw values; mid-run follow-ups read "Queued — read after the current step" until consumed, render as ordinary transcript rows at the point they were sent; standalone approval forms show "Waiting for your answer above"; text from separate assistant steps is joined with a paragraph break.
7
+ - f1b2266: Keep collapsed tool details compact, bound and redact error previews and tooltips, and show complete stored tool text on expansion. Add long-chat DOM and page-reader regression coverage.
8
+ - 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
9
+ shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
10
+ (guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
11
+ `@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
12
+ filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
13
+ breaks are listed in `owner-store-to-server` and `guard-host-ports`.
14
+ - 0a2b903: Share accessible eye toggles across React credential fields, including multiline question answers,
15
+ CLI overrides, provider keys, login, new users, password resets, and source configuration. Preserve
16
+ autofill protection, translated toggle names, agent handles, and secret-answer redaction. Hidden
17
+ source environment blocks keep names visible and stored values masked until explicitly revealed.
18
+
19
+ Chat and user-management require `@jini-ai/ui ^0.5.7` for the new `SecretInput` and `SecretTextarea`
20
+ exports. Release UI 0.5.7 (or adjust those peer floors to the actual release containing these exports)
21
+ with these consumers; older UI versions cannot satisfy their imports. Hosts supply `.jini-secret`,
22
+ `.jini-secret-toggle`, and `.jini-secret--multiline` visual rules.
23
+
24
+ - Updated dependencies [7479159]
25
+ - Updated dependencies [c31264d]
26
+ - Updated dependencies [0728914]
27
+ - Updated dependencies [6ecbdb2]
28
+ - Updated dependencies [63bacc5]
29
+ - @jini-ai/core@0.6.13
30
+
31
+ ## 0.6.12
32
+
33
+ ### Patch Changes
34
+
35
+ - 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.
36
+ - @jini-ai/core@0.6.12
37
+
1
38
  ## 0.6.11
2
39
 
3
40
  ### Patch Changes
@@ -1,3 +1,4 @@
1
- /** Shared bubble styles for server placeholders and mounted DOM widgets; no runtime dependencies. */
2
- export declare const CHAT_BUBBLE_STYLE = ".jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:grid;place-items:center}\n.jini-chat-bubble[hidden]{display:none!important}\n.jini-chat-bubble svg{width:24px;height:24px}\n.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}\n@media(max-width:480px){.jini-chat-bubble{right:16px;bottom:16px}}\n.jini-chat-bubble[aria-busy=\"true\"]{cursor:progress;opacity:.65}\n.jini-chat-bubble[data-load-failed=\"true\"]{outline:2px solid var(--jini-chat-error,#b91c1c);outline-offset:3px}";
1
+ /** Shared bubble styles for server placeholders and mounted DOM widgets; no runtime dependencies.
2
+ * Consent can suspend floating UI without moving a tall open panel beyond the viewport. */
3
+ export declare const CHAT_BUBBLE_STYLE = ".jini-chat-vanilla{display:var(--jini-floating-ui-display,block)}.jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:var(--jini-floating-ui-display,grid);place-items:center}\n.jini-chat-bubble[hidden]{display:none!important}\n.jini-chat-bubble svg{width:24px;height:24px}\n.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}\n@media(max-width:480px){.jini-chat-bubble{right:16px;bottom:16px}}\n.jini-chat-bubble[aria-busy=\"true\"]{cursor:progress;opacity:.65}\n.jini-chat-bubble[data-load-failed=\"true\"]{outline:2px solid var(--jini-chat-error,#b91c1c);outline-offset:3px}";
3
4
  //# sourceMappingURL=chat-bubble-style.d.ts.map
@@ -1,3 +1,4 @@
1
- /** Shared bubble styles for server placeholders and mounted DOM widgets; no runtime dependencies. */
2
- export const CHAT_BUBBLE_STYLE = '.jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:grid;place-items:center}\n.jini-chat-bubble[hidden]{display:none!important}\n.jini-chat-bubble svg{width:24px;height:24px}\n.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}\n@media(max-width:480px){.jini-chat-bubble{right:16px;bottom:16px}}\n.jini-chat-bubble[aria-busy="true"]{cursor:progress;opacity:.65}\n.jini-chat-bubble[data-load-failed="true"]{outline:2px solid var(--jini-chat-error,#b91c1c);outline-offset:3px}';
1
+ /** Shared bubble styles for server placeholders and mounted DOM widgets; no runtime dependencies.
2
+ * Consent can suspend floating UI without moving a tall open panel beyond the viewport. */
3
+ export const CHAT_BUBBLE_STYLE = '.jini-chat-vanilla{display:var(--jini-floating-ui-display,block)}.jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:var(--jini-floating-ui-display,grid);place-items:center}\n.jini-chat-bubble[hidden]{display:none!important}\n.jini-chat-bubble svg{width:24px;height:24px}\n.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}\n@media(max-width:480px){.jini-chat-bubble{right:16px;bottom:16px}}\n.jini-chat-bubble[aria-busy="true"]{cursor:progress;opacity:.65}\n.jini-chat-bubble[data-load-failed="true"]{outline:2px solid var(--jini-chat-error,#b91c1c);outline-offset:3px}';
3
4
  //# sourceMappingURL=chat-bubble-style.js.map
@@ -3,6 +3,10 @@ import type { ChatAttachment, ChatMessage } from './messages.js';
3
3
  export interface AcceptedChatInput {
4
4
  id: string;
5
5
  text: string;
6
+ /** Standalone questions have no owning tool id; a receipt answers the preceding document at its URI. */
7
+ surfaceUri?: string;
8
+ /** The card's answer without its title; text retains the complete user bubble. */
9
+ answerText?: string;
6
10
  /** Absent for legacy/ordinary inputs; consumed only after a CLI UUID receipt. */
7
11
  deliveryState?: 'queued' | 'consumed';
8
12
  attachments?: ChatAttachment[];
@@ -27,10 +31,25 @@ type UserMessageEvent = Extract<AgentEvent, {
27
31
  inputs?: AcceptedChatInput[];
28
32
  };
29
33
  };
34
+ /** Hosts can recover standalone form labels without adding a DOM dependency to core. */
35
+ export interface AcceptedInputProjectionOptions {
36
+ answerContexts?: ReadonlyMap<string, {
37
+ input: unknown;
38
+ includeTitle?: boolean;
39
+ }>;
40
+ }
30
41
  /** Reads the existing event-backed user bubble without depending on a host renderer. */
31
42
  export declare function acceptedInputEvent({ event }: {
32
43
  event: AgentEvent;
33
44
  }, _options?: {}): UserMessageEvent | null;
45
+ /** Bind each unique receipt to the preceding document, never a later outcome or retry at its URI.
46
+ * Both transcript closure and activity use this projection; resource identity alone is not an answer.
47
+ * @returns Answer labels keyed by the original ext-event data; no caller mutation or I/O.
48
+ * @complexity O(e) time/space for events, including replay deduplication by accepted-input id.
49
+ */
50
+ export declare function recordedSurfaceAnswers({ events }: {
51
+ events: readonly AgentEvent[];
52
+ }, _options?: {}): ReadonlyMap<unknown, string>;
34
53
  /** Builds the same bubble for delivered answers and steering; queues use the existing pending row. */
35
54
  export declare function acceptedInputUserEvent({ input, inputs, toolUseId, afterInputId }: {
36
55
  input: AcceptedChatInput;
@@ -47,7 +66,7 @@ export declare function acceptedInputUserEvent({ input, inputs, toolUseId, after
47
66
  */
48
67
  export declare function projectAcceptedInputEvents({ events }: {
49
68
  events: readonly AgentEvent[];
50
- }, _options?: {}): AgentEvent[];
69
+ }, options?: AcceptedInputProjectionOptions): AgentEvent[];
51
70
  /** The sole transcript-to-recall projection. A merged queued turn retains its original inputs. */
52
71
  export declare function acceptedInputsFromMessages({ messages }: {
53
72
  messages: readonly ChatMessage[];
@@ -1,5 +1,5 @@
1
1
  /** Accepted composer input has one identity across optimistic display, replay and recall. */
2
- import { questionAnswerFromResult } from './question-answer.js';
2
+ import { questionAnswerFromResult, questionAnswerLabel } from './question-answer.js';
3
3
  import { MID_RUN_USER_MESSAGE_EVENT_NAME } from './turn-usage.js';
4
4
  /** Display queue constituents using their accepted identities; storage and delivery retain one turn.
5
5
  * MOVE: Receipts travel in events_json, so separate bubbles survive a history reload without another store.
@@ -28,22 +28,53 @@ export function acceptedInputEvent({ event }, _options = {}) {
28
28
  const data = event.data;
29
29
  return data && typeof data.id === 'string' && typeof data.text === 'string' ? event : null;
30
30
  }
31
+ /** Read resource identity without importing the browser/UI parser into core. */
32
+ function surfaceUriOf({ event }, _options = {}) {
33
+ if (event.kind !== 'ext' || event.name !== 'mcp-ui')
34
+ return undefined;
35
+ const data = event.data;
36
+ return typeof data?.resource?.uri === 'string' ? data.resource.uri : undefined;
37
+ }
38
+ /** Bind each unique receipt to the preceding document, never a later outcome or retry at its URI.
39
+ * Both transcript closure and activity use this projection; resource identity alone is not an answer.
40
+ * @returns Answer labels keyed by the original ext-event data; no caller mutation or I/O.
41
+ * @complexity O(e) time/space for events, including replay deduplication by accepted-input id.
42
+ */
43
+ export function recordedSurfaceAnswers({ events }, _options = {}) {
44
+ const latest = new Map();
45
+ const answers = new Map();
46
+ const seen = new Set();
47
+ for (const event of events) {
48
+ const uri = surfaceUriOf({ event });
49
+ if (uri !== undefined && event.kind === 'ext')
50
+ latest.set(uri, event.data);
51
+ const receipt = acceptedInputEvent({ event })?.data;
52
+ if (typeof receipt?.surfaceUri !== 'string' || seen.has(receipt.id))
53
+ continue;
54
+ seen.add(receipt.id);
55
+ const document = latest.get(receipt.surfaceUri);
56
+ if (document !== undefined)
57
+ answers.set(document, typeof receipt.answerText === 'string' ? receipt.answerText : receipt.text);
58
+ }
59
+ return answers;
60
+ }
31
61
  /** Project a persisted answer through the same receipt owner as composer input. */
32
- function questionAnswerEvent({ event, input }, _options = {}) {
62
+ function questionAnswerEvent({ event, input }, { includeTitle = false } = {}) {
33
63
  if (event.kind !== 'tool_result')
34
64
  return null;
35
65
  const text = questionAnswerFromResult({ result: event, input });
36
66
  if (text === undefined)
37
67
  return null;
68
+ // A standalone form's bubble reads like its closed card: "<title>: <answer>".
38
69
  return { kind: 'ext', name: MID_RUN_USER_MESSAGE_EVENT_NAME,
39
- data: { id: `answer:${event.toolUseId}`, text, toolUseId: event.toolUseId } };
70
+ data: { id: `answer:${event.toolUseId}`, text: includeTitle ? questionAnswerLabel({ input, text }) : text, toolUseId: event.toolUseId } };
40
71
  }
41
72
  /** Builds the same bubble for delivered answers and steering; queues use the existing pending row. */
42
73
  export function acceptedInputUserEvent({ input, inputs, toolUseId, afterInputId }, { receipt = 'optimistic' } = {}) {
43
74
  return { kind: 'ext', name: MID_RUN_USER_MESSAGE_EVENT_NAME,
44
75
  data: { ...input, ...(receipt === 'optimistic' ? { optimistic: true } : {}), ...(inputs === undefined ? {} : { inputs }), ...(afterInputId === undefined ? {} : { afterInputId }), ...(toolUseId === undefined ? {} : { toolUseId }) } };
45
76
  }
46
- function expandQuestionAnswers({ events }, _options = {}) {
77
+ function expandQuestionAnswers({ events }, { answerContexts } = {}) {
47
78
  const savedAnswers = new Set(events.flatMap(event => {
48
79
  const input = acceptedInputEvent({ event });
49
80
  return input && !input.data.optimistic && input.data.toolUseId ? [input.data.toolUseId] : [];
@@ -51,7 +82,9 @@ function expandQuestionAnswers({ events }, _options = {}) {
51
82
  // MOVE: Persisted picks carry values; the original tool input retains their human-readable labels.
52
83
  const questions = new Map(events.flatMap(event => event.kind === 'tool_use' ? [[event.id, event.input]] : []));
53
84
  const answers = new Map(events.flatMap(event => {
54
- const answer = questionAnswerEvent({ event, input: event.kind === 'tool_result' ? questions.get(event.toolUseId) : undefined });
85
+ const context = event.kind === 'tool_result' ? answerContexts?.get(event.toolUseId) : undefined;
86
+ const input = context ? context.input : event.kind === 'tool_result' ? questions.get(event.toolUseId) : undefined;
87
+ const answer = questionAnswerEvent({ event, input }, { includeTitle: context?.includeTitle === true });
55
88
  return answer ? [[answer.data.toolUseId, answer]] : [];
56
89
  }));
57
90
  return events.flatMap(event => {
@@ -87,10 +120,17 @@ function matchOptimisticInput({ input, optimistic, seen }, _options = {}) {
87
120
  // Keep predecessor testing before the caller adds this receipt's ids to seen.
88
121
  return input.data.toolUseId ? optimistic.byQuestion.get(input.data.toolUseId) : follows ? next : undefined;
89
122
  }
123
+ /** Apply authoritative receipt data while retaining local identity and queued constituents. */
90
124
  function mergeAcceptedReceipt({ input, pending }, _options = {}) {
91
- return { ...input, data: { ...input.data, id: pending.data.id,
92
- ...(pending.data.inputs ? { inputs: pending.data.inputs.map(item => ({ ...item, ...(input.data.deliveryState ? { deliveryState: input.data.deliveryState } : {}) })) } : {}),
93
- aliases: [...new Set([...(input.data.aliases ?? []), input.data.id, ...(pending.data.aliases ?? [])])] } };
125
+ // Replayed queue acceptance cannot undo confirmed consumption.
126
+ const deliveryState = pending.data.deliveryState === 'consumed' ? 'consumed' : input.data.deliveryState ?? pending.data.deliveryState;
127
+ const { optimistic: _optimistic, afterInputId: _afterInputId, ...pendingData } = pending.data;
128
+ const aliases = [...new Set([...(input.data.aliases ?? []), input.data.id, ...(pending.data.aliases ?? [])])].filter(id => id !== pending.data.id);
129
+ return { ...input, data: { ...pendingData, ...input.data, id: pending.data.id,
130
+ // Legacy acceptance receipts omit state; HTTP acceptance still means queued until consumption.
131
+ ...(deliveryState === undefined ? {} : { deliveryState }),
132
+ ...(pending.data.inputs ? { inputs: pending.data.inputs.map(item => ({ ...item, ...(deliveryState ? { deliveryState } : {}) })) } : {}),
133
+ ...(aliases.length > 0 ? { aliases } : {}) } };
94
134
  }
95
135
  function acceptedInputIds({ input }, _options = {}) {
96
136
  return [input.data.id, ...(input.data.toolUseId ? [`answer:${input.data.toolUseId}`] : []), ...(input.data.aliases ?? [])];
@@ -114,8 +154,9 @@ function reconcileAcceptedInput({ event, optimistic, state }, _options = {}) {
114
154
  for (const id of [pending.data.id, ...(pending.data.aliases ?? [])])
115
155
  seen.add(id);
116
156
  optimistic.byQuestion.delete(input.data.toolUseId ?? '');
117
- replaced.add(pending);
118
- replacements.set(event, mergeAcceptedReceipt({ input, pending }));
157
+ // Acceptance can arrive after the reply it triggered. Keep the position where the person sent it.
158
+ replaced.add(event);
159
+ replacements.set(pending, mergeAcceptedReceipt({ input, pending }));
119
160
  }
120
161
  function advanceOptimisticInput({ input, optimistic }, _options = {}) {
121
162
  const candidates = optimistic.byText.get(input.data.text);
@@ -130,12 +171,9 @@ function collapseDeliveryReceipts({ events }, _options = {}) {
130
171
  for (const event of events) {
131
172
  const input = acceptedInputEvent({ event });
132
173
  const position = input ? positions.get(input.data.id) : undefined;
133
- if (input && position !== undefined && input.data.deliveryState) {
174
+ if (input && position !== undefined && !input.data.optimistic) {
134
175
  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 })) } : {}) } };
176
+ result[position] = mergeAcceptedReceipt({ input, pending: original });
139
177
  continue;
140
178
  }
141
179
  if (input)
@@ -150,8 +188,18 @@ function collapseDeliveryReceipts({ events }, _options = {}) {
150
188
  * match by text; two real events with equal text always remain distinct.
151
189
  * @complexity O(e + u + a) time, O(e + u) space for e events, u user inputs and total answer-label lookup cost a.
152
190
  */
153
- export function projectAcceptedInputEvents({ events }, _options = {}) {
154
- const expanded = expandQuestionAnswers({ events: collapseDeliveryReceipts({ events }) });
191
+ export function projectAcceptedInputEvents({ events }, options) {
192
+ if (options !== undefined)
193
+ return projectEvents({ events }, options);
194
+ // Event arrays are replaced, never mutated, so a transcript update re-projects only changed messages.
195
+ let projected = defaultProjections.get(events);
196
+ if (!projected)
197
+ defaultProjections.set(events, projected = projectEvents({ events }, {}));
198
+ return projected;
199
+ }
200
+ const defaultProjections = new WeakMap();
201
+ function projectEvents({ events }, options) {
202
+ const expanded = expandQuestionAnswers({ events: collapseDeliveryReceipts({ events }) }, options);
155
203
  const optimistic = collectOptimisticInputs({ events: expanded });
156
204
  const state = { replacements: new Map(), replaced: new Set(), seen: new Set() };
157
205
  for (const event of expanded)
@@ -72,10 +72,8 @@ export declare function createAgUiToAgentTranslationState({ customEventNames }:
72
72
  * Translates one AG-UI event into zero or more chat-core `AgentEvent`s, threading `state` across
73
73
  * calls within a single run for tool-call argument accumulation.
74
74
  *
75
- * `TEXT_MESSAGE_START`/`END` and the `REASONING_*` boundary markers translate to nothing: chat-core
76
- * groups renderable output by a change in `AgentEvent.kind` alone (no message-boundary concept),
77
- * the same asymmetry `translateRunAgentPayload` already has reducing the OTHER leg of this round
78
- * trip (daemon wire -> `AgentEvent`) — this function is that reduction's mirror image. The two cases
75
+ * `TEXT_MESSAGE_START` preserves an explicit step boundary so two assistant messages cannot fuse
76
+ * into one word. End/reasoning markers carry no text. The two cases
79
77
  * with real internal branching (`TOOL_CALL_END`, `CUSTOM`) and the one with a guard (`TOOL_CALL_ARGS`)
80
78
  * are pulled into their own named functions above so this switch's own cost is just its case count.
81
79
  */
@@ -145,6 +145,14 @@ function accumulateToolCallArgs(toolCallId, delta, state) {
145
145
  if (call)
146
146
  call.argsJson += delta;
147
147
  }
148
+ /** `TEXT_MESSAGE_START` keeps an explicit step boundary so two assistant messages cannot fuse into
149
+ * one word; every other unmapped event translates to nothing. Split out so this `if` is scored in
150
+ * its own complexity budget rather than the switch's. */
151
+ function translateAgUiStepStart({ event }, _options = {}) {
152
+ if (event.type !== EventType.TEXT_MESSAGE_START)
153
+ return [];
154
+ return [{ kind: 'status', code: 'step_start', label: '', data: { messageId: event.messageId } }];
155
+ }
148
156
  /** The `TOOL_CALL_END` case of {@link translateAgUiEventToAgentEvent} — parses the accumulated
149
157
  * args buffer (already removed from `state` by the caller) into one `tool_use` event, or `[]` for
150
158
  * an id with no matching `TOOL_CALL_START`. */
@@ -177,10 +185,8 @@ function translateAgUiCustomEvent(event, names) {
177
185
  * Translates one AG-UI event into zero or more chat-core `AgentEvent`s, threading `state` across
178
186
  * calls within a single run for tool-call argument accumulation.
179
187
  *
180
- * `TEXT_MESSAGE_START`/`END` and the `REASONING_*` boundary markers translate to nothing: chat-core
181
- * groups renderable output by a change in `AgentEvent.kind` alone (no message-boundary concept),
182
- * the same asymmetry `translateRunAgentPayload` already has reducing the OTHER leg of this round
183
- * trip (daemon wire -> `AgentEvent`) — this function is that reduction's mirror image. The two cases
188
+ * `TEXT_MESSAGE_START` preserves an explicit step boundary so two assistant messages cannot fuse
189
+ * into one word. End/reasoning markers carry no text. The two cases
184
190
  * with real internal branching (`TOOL_CALL_END`, `CUSTOM`) and the one with a guard (`TOOL_CALL_ARGS`)
185
191
  * are pulled into their own named functions above so this switch's own cost is just its case count.
186
192
  */
@@ -211,7 +217,7 @@ export function translateAgUiEventToAgentEvent({ event, state }, _options = {})
211
217
  case EventType.CUSTOM:
212
218
  return translateAgUiCustomEvent(event, state.customEventNames);
213
219
  default:
214
- return [];
220
+ return translateAgUiStepStart({ event });
215
221
  }
216
222
  }
217
223
  //# sourceMappingURL=entry.js.map
@@ -79,7 +79,7 @@ export function createAgUiRunClient({ ports }, _options = {}) {
79
79
  handlers.onDone(collected);
80
80
  };
81
81
  const state = createAgUiToAgentTranslationState({ customEventNames: ports.customEventNames }, {});
82
- // Deliberately racing the Observable's first `next`/`error` against this Promise rather than
82
+ // Deliberately racing the Observable's first `next`/`error`/`complete` against this Promise rather than
83
83
  // awaiting it directly — `HttpAgent.run()` returns a cold Observable, never a Promise (module
84
84
  // doc). The subscription itself keeps running after this Promise settles, driving `handlers`
85
85
  // through to `finish()` in the background — matching `startByokRun`'s "resolves once STARTED, not
@@ -115,6 +115,11 @@ export function createAgUiRunClient({ ports }, _options = {}) {
115
115
  finish();
116
116
  },
117
117
  complete: () => {
118
+ // An empty stream has no first notification to settle the start promise.
119
+ if (!startSettled) {
120
+ startSettled = true;
121
+ resolve({ runId });
122
+ }
118
123
  finish();
119
124
  },
120
125
  });
@@ -28,7 +28,8 @@ type CheckpointHandlers = RunHandlers & {
28
28
  onCheckpoint?: (message: ChatMessage) => void;
29
29
  };
30
30
  /** SSE is a view of one attempt. The saved message owns terminal state and can point at a new
31
- * attempt after recovery; neither a browser 404 nor a closed stream may fail the logical run. */
31
+ * attempt after recovery; neither a browser 404 nor a closed stream may fail the logical run.
32
+ * An explicit "gone" result ends observation and releases the caller's streaming state. */
32
33
  export declare function followDurableRun({ runId: initialRunId, handlers, signal, binding, ports, notices, bindings }: {
33
34
  runId: string;
34
35
  handlers: CheckpointHandlers;
@@ -1,6 +1,7 @@
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
- * attempt after recovery; neither a browser 404 nor a closed stream may fail the logical run. */
3
+ * attempt after recovery; neither a browser 404 nor a closed stream may fail the logical run.
4
+ * An explicit "gone" result ends observation and releases the caller's streaming state. */
4
5
  export function followDurableRun({ runId: initialRunId, handlers, signal, binding, ports, notices, bindings }, { logger = console } = {}) {
5
6
  let streamEpoch = 0;
6
7
  let runId = initialRunId;
@@ -93,6 +94,7 @@ export function followDurableRun({ runId: initialRunId, handlers, signal, bindin
93
94
  return;
94
95
  if (message === "gone") {
95
96
  stop();
97
+ handlers.onDone([]);
96
98
  return;
97
99
  }
98
100
  applySavedMessage(message);
@@ -406,7 +406,11 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
406
406
  const runId = mintByokRunId();
407
407
  const controller = new AbortController();
408
408
  byokAbortControllers.set(runId, controller);
409
- input.signal?.addEventListener("abort", () => controller.abort());
409
+ // Conversation adoption may have yielded while the subscription was torn down.
410
+ if (input.signal?.aborted)
411
+ controller.abort();
412
+ else
413
+ input.signal?.addEventListener("abort", () => controller.abort());
410
414
  let response;
411
415
  try {
412
416
  response = await ports.fetch(ports.byokTurnUrl, {
@@ -17,6 +17,13 @@
17
17
  import type { AgentEvent } from './events.js';
18
18
  /** What separates two text runs that a tool call or card sat between. */
19
19
  export declare const ASSISTANT_STEP_SEPARATOR = "\n\n";
20
+ /** Only explicit step transitions separate text. Thinking-token heartbeats and raw diagnostics
21
+ * may occur inside a streamed step and must never split its deltas.
22
+ * @complexity O(1); no mutation or I/O.
23
+ */
24
+ export declare function isAssistantStepBoundary({ event }: {
25
+ event: AgentEvent;
26
+ }, _options?: {}): boolean;
20
27
  /**
21
28
  * A turn's text, with a paragraph break between runs separated by a tool call or card.
22
29
  * A replay of an already-seen tool ID is not a new step, matching the saved-event projection.
@@ -26,7 +33,7 @@ export declare const ASSISTANT_STEP_SEPARATOR = "\n\n";
26
33
  */
27
34
  export declare function assistantContentFromEvents({ events }: {
28
35
  events: readonly AgentEvent[] | undefined;
29
- }): string;
36
+ }, _options?: {}): string;
30
37
  /** The pre-2026-09-27 rule (plain concatenation), kept only so older saved rows still match. */
31
38
  export declare function legacyAssistantContentFromEvents({ events }: {
32
39
  events: readonly AgentEvent[] | undefined;
@@ -1,11 +1,24 @@
1
1
  import { dedupeToolUsesById } from './tool-events.js';
2
2
  /** What separates two text runs that a tool call or card sat between. */
3
3
  export const ASSISTANT_STEP_SEPARATOR = '\n\n';
4
- function separatorBefore(out, text) {
4
+ function separatorBefore({ out, text }, _options = {}) {
5
5
  if (out.length === 0 || out.endsWith(ASSISTANT_STEP_SEPARATOR) || text.startsWith('\n'))
6
6
  return '';
7
7
  return out.endsWith('\n') ? '\n' : ASSISTANT_STEP_SEPARATOR;
8
8
  }
9
+ /** Only explicit step transitions separate text. Thinking-token heartbeats and raw diagnostics
10
+ * may occur inside a streamed step and must never split its deltas.
11
+ * @complexity O(1); no mutation or I/O.
12
+ */
13
+ export function isAssistantStepBoundary({ event }, _options = {}) {
14
+ if (event.kind === 'tool_use' || event.kind === 'ext' || event.kind === 'usage')
15
+ return true;
16
+ if (event.kind !== 'status')
17
+ return false;
18
+ if (event.code !== undefined)
19
+ return event.code === 'step_start';
20
+ return event.label === 'thinking' || event.label === 'running';
21
+ }
9
22
  /**
10
23
  * A turn's text, with a paragraph break between runs separated by a tool call or card.
11
24
  * A replay of an already-seen tool ID is not a new step, matching the saved-event projection.
@@ -13,15 +26,15 @@ function separatorBefore(out, text) {
13
26
  * @param events - The turn's events, in arrival order.
14
27
  * @complexity O(n + total text length) time and O(n) space for n events.
15
28
  */
16
- export function assistantContentFromEvents({ events }) {
29
+ export function assistantContentFromEvents({ events }, _options = {}) {
17
30
  let out = '';
18
31
  let stepBoundary = false;
19
32
  for (const ev of dedupeToolUsesById({ events: events ? [...events] : undefined })) {
20
- if (ev.kind === 'tool_use' || ev.kind === 'ext') {
33
+ if (isAssistantStepBoundary({ event: ev })) {
21
34
  stepBoundary = true;
22
35
  }
23
36
  else if (ev.kind === 'text' && ev.text.length > 0) {
24
- out += (stepBoundary ? separatorBefore(out, ev.text) : '') + ev.text;
37
+ out += (stepBoundary ? separatorBefore({ out, text: ev.text }) : '') + ev.text;
25
38
  stepBoundary = false;
26
39
  }
27
40
  }
@@ -16,8 +16,12 @@ function delegatedCall(event) {
16
16
  return { toolId: input.toolId, input: input.input };
17
17
  return { toolId: event.name, input: event.input };
18
18
  }
19
+ /** Results that settle their call. An outcome-unknown result does not: the call may have run. */
20
+ function settledResults({ events }, _optional = {}) {
21
+ return new Map(events.flatMap((event) => event.kind === "tool_result" && event.outcome !== "unknown" ? [[event.toolUseId, event]] : []));
22
+ }
19
23
  export function hasUnknownToolCall({ events, toolId, input }, _optional = {}) {
20
- const completed = new Set(events.filter((event) => event.kind === "tool_result").map((event) => event.toolUseId));
24
+ const completed = settledResults({ events }, {});
21
25
  const canonical = canonicalToolInput({ value: input }, {});
22
26
  return events.some((event) => {
23
27
  if (event.kind !== "tool_use" || completed.has(event.id))
@@ -30,7 +34,7 @@ export function hasUnknownToolCall({ events, toolId, input }, _optional = {}) {
30
34
  });
31
35
  }
32
36
  function toolHistory(events) {
33
- const results = new Map(events.filter((event) => event.kind === "tool_result").map((event) => [event.toolUseId, event]));
37
+ const results = settledResults({ events }, {});
34
38
  return events.filter((event) => event.kind === "tool_use").map((event) => {
35
39
  const result = results.get(event.id);
36
40
  return JSON.stringify({ call: delegatedCall(event), result: result ?? "OUTCOME UNKNOWN: verify state before repeating" });
@@ -60,6 +60,9 @@ export type AgentEvent = {
60
60
  /** Typed media (currently just images) a renderer may show directly. Absent for the
61
61
  * overwhelming majority of tool results, which carry none. */
62
62
  media?: readonly ToolResultMediaBlock[];
63
+ /** Set when the call may or may not have run (its tool RPC connection broke after sending).
64
+ * Such a result does not complete the call: a repeat must verify state first. */
65
+ outcome?: 'unknown';
63
66
  } | {
64
67
  kind: 'usage';
65
68
  inputTokens?: number;
@@ -38,4 +38,5 @@ export * from './history-tool-metadata.js';
38
38
  export { turnUsage, conversationUsage, replySegments, isReplyBoundary, MID_RUN_USER_MESSAGE_EVENT_NAME, type ChatUsageSummary } from './turn-usage.js';
39
39
  export { formatUsageCost } from './usage-format.js';
40
40
  export * from './accepted-input.js';
41
+ export * from './question-answer.js';
41
42
  //# sourceMappingURL=index.d.ts.map
@@ -37,4 +37,5 @@ export * from './history-tool-metadata.js';
37
37
  export { turnUsage, conversationUsage, replySegments, isReplyBoundary, MID_RUN_USER_MESSAGE_EVENT_NAME } from './turn-usage.js';
38
38
  export { formatUsageCost } from './usage-format.js';
39
39
  export * from './accepted-input.js';
40
+ export * from './question-answer.js';
40
41
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,11 @@
1
+ /** @module question-answer — plain transcript text from the ask-choice result contract. */
2
+ /** Optional question context on an MCP-UI resource, before HTML presentation flattens its labels. */
3
+ export declare const SURFACE_QUESTION_META_KEY = "x-jini-chat/question";
4
+ /** The shared title/answer format for closed cards and standalone form receipts. */
5
+ export declare function questionAnswerLabel({ input, text }: {
6
+ input: unknown;
7
+ text: string;
8
+ }, _options?: {}): string;
1
9
  /** Read a classified result or delivered callback as plain user text. Decline wins over other fields;
2
10
  * expiry, invalid answers and unrecognized results produce no user message.
3
11
  * @returns Answer text, or undefined when no supported human answer is present; never throws.
@@ -1,13 +1,25 @@
1
1
  /** @module question-answer — plain transcript text from the ask-choice result contract. */
2
+ /** Optional question context on an MCP-UI resource, before HTML presentation flattens its labels. */
3
+ export const SURFACE_QUESTION_META_KEY = 'x-jini-chat/question';
2
4
  function record(value) {
3
5
  return typeof value === 'object' && value !== null ? value : {};
4
6
  }
7
+ /** The shared title/answer format for closed cards and standalone form receipts. */
8
+ export function questionAnswerLabel({ input, text }, _options = {}) {
9
+ const question = record(input);
10
+ const title = [question.title, question.question, question.prompt]
11
+ .find(value => typeof value === 'string' && value.trim() !== '');
12
+ return typeof title === 'string' ? `${title}: ${text}` : text;
13
+ }
5
14
  /** Resolve a stored option value through the question that offered it. Unknown values stay readable. */
6
- function optionLabel(group, value) {
15
+ function optionLabel({ group, value }, _options = {}) {
7
16
  const options = record(group).options;
8
17
  if (!Array.isArray(options))
9
18
  return value;
10
- const option = options.find(candidate => record(candidate).value === value);
19
+ const option = options.find(candidate => {
20
+ const offered = record(candidate);
21
+ return (offered.value ?? offered.id ?? offered.label) === value;
22
+ });
11
23
  const label = record(option).label;
12
24
  return typeof label === 'string' ? label : value;
13
25
  }
@@ -15,11 +27,11 @@ function selectedAnswerText({ result, input }, _options = {}) {
15
27
  const question = record(input);
16
28
  const labels = [];
17
29
  if (typeof result.choice === 'string')
18
- labels.push(optionLabel(question.singleSelect ?? question, result.choice));
30
+ labels.push(optionLabel({ group: question.singleSelect ?? question, value: result.choice }));
19
31
  if (Array.isArray(result.selections)) {
20
32
  for (const selection of result.selections) {
21
33
  if (typeof selection === 'string')
22
- labels.push(optionLabel(question.multiSelect ?? question, selection));
34
+ labels.push(optionLabel({ group: question.multiSelect ?? question, value: selection }));
23
35
  }
24
36
  }
25
37
  if (labels.length > 0)
@@ -55,7 +55,7 @@ export interface RunActivityOptions {
55
55
  * card); the newest open tool call; else
56
56
  * writing (last visible event is text) or thinking.
57
57
  *
58
- * @complexity O(n) in events, O(open tool calls) extra space.
58
+ * @complexity O(n) time in events, O(open tool calls + answered surfaces) extra space.
59
59
  */
60
60
  export declare function deriveRunActivity({ events }: {
61
61
  events: readonly AgentEvent[] | undefined;