@jini-ai/chat 0.6.6 → 0.6.10

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 (160) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +187 -98
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.d.ts +1 -1
  77. package/dist/react/components/MessageRow.js +75 -47
  78. package/dist/react/components/MessageRow.usage.js +16 -11
  79. package/dist/react/components/MidRunUserMessage.js +16 -3
  80. package/dist/react/components/QuestionForm.js +148 -76
  81. package/dist/react/components/QuestionsPanel.js +1 -1
  82. package/dist/react/components/TodoCard.js +12 -6
  83. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  84. package/dist/react/components/ToolCallGroup.js +7 -5
  85. package/dist/react/components/ToolCard.js +47 -40
  86. package/dist/react/components/composer-discovery.js +6 -1
  87. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  88. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  89. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  90. package/dist/react/ext-event-renderer-registry.js +5 -2
  91. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  93. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  94. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  95. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  96. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  98. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  99. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  100. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  102. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  104. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  106. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  108. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  109. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  110. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  112. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  114. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  115. package/dist/react/features/chat-pane/model-options.js +45 -25
  116. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  117. package/dist/react/features/chat-pane/rules.js +84 -46
  118. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  119. package/dist/react/features/chat-pane/styles.js +152 -36
  120. package/dist/react/features/chat-pane/types.d.ts +19 -9
  121. package/dist/react/hooks/assistant-chats.js +12 -5
  122. package/dist/react/hooks/composer-draft-cache.js +116 -118
  123. package/dist/react/hooks/composer-history-storage.js +5 -5
  124. package/dist/react/hooks/fab-position.js +7 -4
  125. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  126. package/dist/react/hooks/useAnsweredCards.js +38 -11
  127. package/dist/react/hooks/useArtifactStream.js +28 -22
  128. package/dist/react/hooks/useComposer.d.ts +20 -0
  129. package/dist/react/hooks/useComposer.js +22 -9
  130. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  131. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  132. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  133. package/dist/react/hooks/useConversation.d.ts +7 -0
  134. package/dist/react/hooks/useConversation.js +23 -10
  135. package/dist/react/hooks/useExtEventGroups.js +39 -39
  136. package/dist/react/hooks/useQuestionForms.js +21 -26
  137. package/dist/react/hooks/useRunStream.js +10 -2
  138. package/dist/react/index.d.ts +7 -1
  139. package/dist/react/index.js +9 -0
  140. package/dist/react/message-blocks.d.ts +6 -4
  141. package/dist/react/message-blocks.js +94 -72
  142. package/dist/react/slots.d.ts +2 -2
  143. package/dist/react/tool-call-display.d.ts +12 -6
  144. package/dist/react/tool-call-display.js +53 -24
  145. package/dist/server/durable-runs/recover.js +132 -80
  146. package/dist/server/run-finalizer/entry.js +25 -15
  147. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  148. package/dist/store/legacy/sqlite/conversations.js +69 -98
  149. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  150. package/dist/store/legacy/sqlite/messages.js +113 -71
  151. package/dist/store/runtime/chat-history-store.js +9 -3
  152. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  153. package/dist/store/runtime/durable-run-store.js +165 -117
  154. package/dist/store/runtime/history-tools.js +118 -82
  155. package/dist/store/runtime/run-ledger.d.ts +4 -1
  156. package/dist/store/runtime/run-ledger.js +56 -16
  157. package/dist/store/sql/cursor.js +38 -18
  158. package/dist/store/sql/store.d.ts +18 -0
  159. package/dist/store/sql/store.js +46 -15
  160. package/package.json +18 -16
@@ -3,9 +3,10 @@
3
3
  * compaction and paragraph boundaries so live and persisted turns agree. Notice strings, custom
4
4
  * event names and excluded run-ID prefixes are explicit host inputs, never product wire defaults.
5
5
  * Authenticated routing, ownership, boot repair and storage transactions remain host responsibilities. */
6
- import { decodeSseFrames, parseSseRecord } from "@jini-ai/agent-runtime/providers/sse-decode";
6
+ import { decodeSseFrames, parseSseRecord } from "@jini-ai/core/primitives";
7
7
  import { assistantContentFromEvents } from "../assistant-content.js";
8
8
  import { mergeAdjacentTextEvents } from "../compact-events.js";
9
+ import { dedupeToolUsesById } from "../tool-events.js";
9
10
  function wireString(v) {
10
11
  return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
11
12
  }
@@ -63,38 +64,13 @@ export function parseUsageEvent({ payload }, _options = {}) {
63
64
  * cannot fall through to `default` — see below). A lookup table would have to carry those as a
64
65
  * parallel structure, one step removed from the code they explain. Left as a `switch`, documented
65
66
  * here rather than only in this session's report.
67
+ *
68
+ * Superseded (2026-10-20, code-health wave 1; bar is ≤10/≤10): the flat switch is now TWO switches —
69
+ * the chat-core event types here, and the UI-envelope / dropped / `ext` types in
70
+ * {@link translateEnvelopePayload} below — so each scope stays under the cyclomatic bar while BOTH
71
+ * keep what the paragraph above argued for: every case is still a `case` with its own comment
72
+ * attached, not an entry in a parallel lookup table.
66
73
  */
67
- // Typed media (currently just images) the daemon attached alongside the flattened `content`
68
- // string — see `@jini-ai/protocol`'s `events.ts` doc on why the wire field is `unknown`
69
- // rather than a checked type here: the real shape is validated where `ToolCard` renders it.
70
- // Forwarded verbatim rather than re-validated a second time in this reducer — an
71
- // `Array.isArray` guard rather than a deep shape check, since a malformed entry inside it
72
- // is a rendering concern (`ToolCard`'s own `ToolResultMedia` already ignores anything that
73
- // isn't a recognized block), not a transport one.
74
- // An MCP content block the daemon withheld from the tool result because it is for the HUMAN,
75
- // not the model (`@jini-ai/daemon`'s `delegated-tool-bridge.ts` → `tool-result-surfaces.ts`).
76
- // Explicit rather than left to `default` below because the shapes do not line up: the default
77
- // passes the WHOLE wire payload as `data`, but `@jini-ai/chat`'s `McpUiSurfaceCard` runs
78
- // `parseUIResource` over each event's `data` and that requires the bare `EmbeddedResource`
79
- // (`{type:'resource', resource:{uri,mimeType,text}}`). Handing it the envelope instead fails
80
- // the `type !== 'resource'` check and renders an empty frame — a silent no-op, which is the
81
- // worst possible failure for a confirmation dialog. Unwrapping here is what makes the two ends
82
- // meet. `name` must stay `"mcp-ui"` to match `MCP_UI_EXT_EVENT_NAME`.
83
- // A2UI's own agent->renderer envelope (`@jini-ai/core`'s `SurfaceEmission` with
84
- // `channel: "a2ui"`, injected by `@jini-ai/daemon`'s `delegated-tool-bridge.ts` as
85
- // `{type: "a2ui", message: <AgentToRendererMessage>}`). Unwrapped to the bare `.message` here,
86
- // not left to the `default` branch below, for the same reason `mcp-ui` above is explicit:
87
- // `@jini-ai/chat/react`'s `A2uiSurfaceCard` (registered against `'a2ui'` in
88
- // `AssistantDock.tsx`) runs `extractSurfaceId`/`interpreter.applyAgentMessage` over each event's
89
- // `data` directly, and both require a bare, spec-shaped envelope — not the `{type, message}`
90
- // wire wrapper. Mirrors Jini's own reference host's identical `case "a2ui"` in
91
- // `examples/reference-web/src/daemon-transport.ts`.
92
- // thinking_start/stage_start/stage_end/surface_request/surface_response: no dedicated
93
- // chat-core variant. Routed through the `ext` escape hatch rather than dropped, so a future
94
- // renderer can opt in without a transport change.
95
- // Streamed tool input (with `--include-partial-messages`): the finished `tool_use` carries the
96
- // same input whole, and nothing renders the fragments. Kept, they became one no-op `ext` event
97
- // per chunk in the saved row.
98
74
  /** Reduce one daemon payload; preserve UI envelopes and drop duplicate input fragments. */
99
75
  export function translateRunAgentPayload({ payload }, _options = {}) {
100
76
  switch (payload.type) {
@@ -107,25 +83,66 @@ export function translateRunAgentPayload({ payload }, _options = {}) {
107
83
  case "tool_use":
108
84
  return { kind: "tool_use", id: wireString(payload.id), name: wireString(payload.name), input: payload.input };
109
85
  case "tool_result":
110
- return {
111
- kind: "tool_result",
112
- toolUseId: wireString(payload.toolUseId),
113
- content: wireString(payload.content),
114
- isError: Boolean(payload.isError),
115
- ...(Array.isArray(payload.media) ? { media: payload.media } : {}),
116
- };
86
+ return toolResultEvent(payload);
117
87
  case "usage":
118
88
  return parseUsageEvent({ payload });
119
89
  case "raw":
120
90
  return { kind: "raw", line: wireString(payload.line) };
91
+ default:
92
+ return translateEnvelopePayload(payload);
93
+ }
94
+ }
95
+ function toolResultEvent(payload) {
96
+ return {
97
+ kind: "tool_result",
98
+ toolUseId: wireString(payload.toolUseId),
99
+ content: wireString(payload.content),
100
+ isError: Boolean(payload.isError),
101
+ // Typed media (currently just images) the daemon attached alongside the flattened `content`
102
+ // string — see `@jini-ai/protocol`'s `events.ts` doc on why the wire field is `unknown`
103
+ // rather than a checked type here: the real shape is validated where `ToolCard` renders it.
104
+ // Forwarded verbatim rather than re-validated a second time in this reducer — an
105
+ // `Array.isArray` guard rather than a deep shape check, since a malformed entry inside it
106
+ // is a rendering concern (`ToolCard`'s own `ToolResultMedia` already ignores anything that
107
+ // isn't a recognized block), not a transport one.
108
+ ...(Array.isArray(payload.media) ? { media: payload.media } : {}),
109
+ };
110
+ }
111
+ /** The payload types with no chat-core event of their own: UI envelopes, dropped types, and `ext`. */
112
+ function translateEnvelopePayload(payload) {
113
+ switch (payload.type) {
114
+ // An MCP content block the daemon withheld from the tool result because it is for the HUMAN,
115
+ // not the model (`@jini-ai/daemon`'s `delegated-tool-bridge.ts` → `tool-result-surfaces.ts`).
116
+ // Explicit rather than left to `default` below because the shapes do not line up: the default
117
+ // passes the WHOLE wire payload as `data`, but `@jini-ai/chat`'s `McpUiSurfaceCard` runs
118
+ // `parseUIResource` over each event's `data` and that requires the bare `EmbeddedResource`
119
+ // (`{type:'resource', resource:{uri,mimeType,text}}`). Handing it the envelope instead fails
120
+ // the `type !== 'resource'` check and renders an empty frame — a silent no-op, which is the
121
+ // worst possible failure for a confirmation dialog. Unwrapping here is what makes the two ends
122
+ // meet. `name` must stay `"mcp-ui"` to match `MCP_UI_EXT_EVENT_NAME`.
121
123
  case "mcp-ui":
122
124
  return { kind: "ext", name: "mcp-ui", data: payload.resource };
125
+ // A2UI's own agent->renderer envelope (`@jini-ai/core`'s `SurfaceEmission` with
126
+ // `channel: "a2ui"`, injected by `@jini-ai/daemon`'s `delegated-tool-bridge.ts` as
127
+ // `{type: "a2ui", message: <AgentToRendererMessage>}`). Unwrapped to the bare `.message` here,
128
+ // not left to the `default` branch below, for the same reason `mcp-ui` above is explicit:
129
+ // `@jini-ai/chat/react`'s `A2uiSurfaceCard` (registered against `'a2ui'` in
130
+ // `AssistantDock.tsx`) runs `extractSurfaceId`/`interpreter.applyAgentMessage` over each event's
131
+ // `data` directly, and both require a bare, spec-shaped envelope — not the `{type, message}`
132
+ // wire wrapper. Mirrors Jini's own reference host's identical `case "a2ui"` in
133
+ // `examples/reference-web/src/daemon-transport.ts`.
123
134
  case "a2ui":
124
135
  return { kind: "ext", name: "a2ui", data: payload.message };
125
136
  case "thinking_start":
126
137
  return null;
138
+ // Streamed tool input (with `--include-partial-messages`): the finished `tool_use` carries the
139
+ // same input whole, and nothing renders the fragments. Kept, they became one no-op `ext` event
140
+ // per chunk in the saved row.
127
141
  case "tool_input_delta":
128
142
  return null;
143
+ // thinking_start/stage_start/stage_end/surface_request/surface_response: no dedicated
144
+ // chat-core variant. Routed through the `ext` escape hatch rather than dropped, so a future
145
+ // renderer can opt in without a transport change.
129
146
  default:
130
147
  return { kind: "ext", name: payload.type, data: payload };
131
148
  }
@@ -175,18 +192,9 @@ function neverStarted(outcome) {
175
192
  }
176
193
  /** Read non-successful daemon classification without throwing on malformed frames. */
177
194
  export function readTerminalOutcome({ raw }, _options = {}) {
178
- if (!raw)
195
+ const payload = parseWirePayload(raw);
196
+ if (!payload)
179
197
  return null;
180
- let payload;
181
- try {
182
- const frame = JSON.parse(raw);
183
- if (!isRecord(frame) || !isRecord(frame.payload))
184
- return null;
185
- payload = frame.payload;
186
- }
187
- catch {
188
- return null;
189
- }
190
198
  const status = payload.status;
191
199
  if (status !== "failed" && status !== "canceled")
192
200
  return null;
@@ -375,9 +383,12 @@ export function runContentFromEvents({ events }, _options = {}) {
375
383
  * onto the screen; saved, it only repeats the event wrapper per token. Used by every saver (the server
376
384
  * finalizer and the chat PUT route the browser saves through), so both write the same shape.
377
385
  */
378
- /** Compact adjacent text/thinking deltas for durable storage. */
386
+ /** Compact adjacent text/thinking deltas and keep the first occurrence of each tool-call ID.
387
+ * Replayed tool calls must not become duplicate history rows. Other events retain their order.
388
+ * @complexity O(n + total text length) time and O(n) space for n events.
389
+ */
379
390
  export function runEventsForSave({ events }, _options = {}) {
380
- return mergeAdjacentTextEvents({ events });
391
+ return mergeAdjacentTextEvents({ events: dedupeToolUsesById({ events: [...events] }) });
381
392
  }
382
393
  function parseWire(raw) {
383
394
  if (!raw)
@@ -390,12 +401,18 @@ function parseWire(raw) {
390
401
  return null;
391
402
  }
392
403
  }
404
+ /** A wire frame's `payload` when it is a plain object; `null` for an absent, malformed or bare body. */
405
+ function parseWirePayload(raw) {
406
+ const payload = parseWire(raw)?.payload;
407
+ return isRecord(payload) ? payload : null;
408
+ }
393
409
  function parseJsonLine(line) {
394
410
  if (!line.startsWith("{"))
395
411
  return null;
396
412
  try {
397
- const value = JSON.parse(line);
398
- return typeof value === "object" && value !== null ? value : null;
413
+ // A line starting with `{` either parses to a plain object or throws; it cannot parse to an
414
+ // array, a scalar or `null`, so no shape check is needed after the parse.
415
+ return JSON.parse(line);
399
416
  }
400
417
  catch {
401
418
  return null;
@@ -451,32 +468,41 @@ function chunkEvent(raw, channel, carry) {
451
468
  return [];
452
469
  const chunk = wireString(frame.payload?.chunk);
453
470
  const open = carry ? (carry.openEchoChannels ??= new Set()) : undefined;
471
+ const { kept, statuses, droppedAny } = splitChunk({ chunk, channel }, { open });
472
+ const line = droppedAny ? kept : chunk;
473
+ return line.length > 0 ? [{ kind: "raw", line }, ...statuses] : statuses;
474
+ }
475
+ /** A chunk's non-echo text and heartbeat statuses, and whether any echo piece was dropped. */
476
+ function splitChunk({ chunk, channel }, { open } = {}) {
454
477
  const statuses = [];
455
478
  let kept = "";
456
479
  let droppedAny = false;
457
- let first = true;
458
- for (const piece of chunk.split(/(?<=\n)/)) {
459
- // Only a chunk's first piece can continue the previous chunk's line; splitting on "\n" starts
460
- // every later piece on a fresh line. Channels are tracked apart: stderr can interleave mid-line.
461
- const echo = (first && open?.has(channel) === true) || ECHO_LINE.test(piece.trimStart());
462
- first = false;
463
- if (echo && !piece.endsWith("\n"))
464
- open?.add(channel);
465
- else
466
- open?.delete(channel);
467
- if (echo) {
480
+ chunk.split(/(?<=\n)/).forEach((piece, index) => {
481
+ if (isEchoPiece({ piece, channel, first: index === 0 }, { open })) {
468
482
  droppedAny = true;
469
- continue;
483
+ return;
470
484
  }
471
- const line = parseJsonLine(piece.trim());
472
- const status = line ? heartbeatStatus(line) : null;
485
+ const status = pieceStatus(piece);
473
486
  if (status)
474
487
  statuses.push(status);
475
488
  kept += piece;
476
- }
477
- if (!droppedAny)
478
- kept = chunk;
479
- return kept.length > 0 ? [{ kind: "raw", line: kept }, ...statuses] : statuses;
489
+ });
490
+ return { kept, statuses, droppedAny };
491
+ }
492
+ /** Whether a piece belongs to an echo line, updating which channels are mid-way through one. */
493
+ function isEchoPiece({ piece, channel, first }, { open } = {}) {
494
+ // Only a chunk's first piece can continue the previous chunk's line; splitting on "\n" starts
495
+ // every later piece on a fresh line. Channels are tracked apart: stderr can interleave mid-line.
496
+ const echo = (first && open?.has(channel) === true) || ECHO_LINE.test(piece.trimStart());
497
+ if (echo && !piece.endsWith("\n"))
498
+ open?.add(channel);
499
+ else
500
+ open?.delete(channel);
501
+ return echo;
502
+ }
503
+ function pieceStatus(piece) {
504
+ const line = parseJsonLine(piece.trim());
505
+ return line ? heartbeatStatus(line) : null;
480
506
  }
481
507
  // Order matters and matches the browser's `end` listener: the `max_tool_turns` notice first, then
482
508
  // the failed/canceled notice, then the failure itself.
@@ -504,33 +530,37 @@ function endOutcome(raw, notices) {
504
530
  *
505
531
  * @complexity O(size of the frame).
506
532
  */
507
- // The agent CLI's own output. `stderr` is where a dying CLI says WHY it is dying.
508
- // Shown AND saved as a status line, not only reported: the error is live-only state, so without
509
- // the event the saved turn (and a reload) showed a bare "failed" with the reason lost. This is
510
- // where the daemon's plain pre-spawn reason ("The assistant could not start: ...") and the
511
- // host's start refusals reach the user.
512
533
  /** Fold one named daemon frame into events, a failure and an optional terminal status. */
513
534
  export function translateRunFrame({ kind, raw, notices }, { carry } = {}) {
514
535
  switch (kind) {
515
- case "agent": {
516
- const frame = parseWire(raw);
517
- const translated = frame && isRecord(frame.payload) && typeof frame.payload.type === "string" ? translateRunAgentPayload({ payload: frame.payload }) : null;
518
- return { events: translated ? [translated] : [] };
519
- }
536
+ case "agent":
537
+ return { events: agentFrameEvents(raw) };
538
+ // The agent CLI's own output. `stderr` is where a dying CLI says WHY it is dying.
520
539
  case "stdout":
521
540
  case "stderr":
522
541
  return { events: chunkEvent(raw, kind, carry) };
523
- case "error": {
524
- const frame = parseWire(raw);
525
- const message = wireString(frame?.payload?.message) || "agent run failed";
526
- return { events: [{ kind: "status", label: message }], error: new Error(message) };
527
- }
542
+ case "error":
543
+ return errorFrameOutcome(raw);
528
544
  case "end":
529
545
  return endOutcome(raw, notices);
530
546
  default:
531
547
  return { events: [] };
532
548
  }
533
549
  }
550
+ function agentFrameEvents(raw) {
551
+ const payload = parseWirePayload(raw);
552
+ const translated = typeof payload?.type === "string" ? translateRunAgentPayload({ payload: payload }) : null;
553
+ return translated ? [translated] : [];
554
+ }
555
+ // Shown AND saved as a status line, not only reported: the error is live-only state, so without
556
+ // the event the saved turn (and a reload) showed a bare "failed" with the reason lost. This is
557
+ // where the daemon's plain pre-spawn reason ("The assistant could not start: ...") and the
558
+ // host's start refusals reach the user.
559
+ function errorFrameOutcome(raw) {
560
+ const frame = parseWire(raw);
561
+ const message = wireString(frame?.payload?.message) || "agent run failed";
562
+ return { events: [{ kind: "status", label: message }], error: new Error(message) };
563
+ }
534
564
  function isRecord(value) {
535
565
  return typeof value === "object" && value !== null && !Array.isArray(value);
536
566
  }
@@ -7,7 +7,7 @@
7
7
  * no framework.
8
8
  */
9
9
  import type { AgentEvent } from './events.js';
10
- import type { ChatRunStatus } from './messages.js';
10
+ import { type ChatRunStatus } from './messages.js';
11
11
  export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'stopped';
12
12
  export interface TodoItem {
13
13
  content: string;
@@ -1,3 +1,4 @@
1
+ import { isTerminalRunStatus } from './messages.js';
1
2
  const TODO_WRITE_TOOL_NAMES = new Set([
2
3
  'TodoWrite',
3
4
  'todowrite',
@@ -28,39 +29,43 @@ function normalizeTodoStatus(status) {
28
29
  * @complexity O(n) in the number of raw plan entries.
29
30
  */
30
31
  export function parseTodoWriteInput({ input }) {
32
+ const list = todoListOf({ input });
33
+ return list ? list.items.map((todo) => parseTodoItem({ todo })).filter((todo) => todo !== null) : [];
34
+ }
35
+ /** The plan array a tool input carries (`todos` wins over `plan`), with the key it sits under. */
36
+ function todoListOf({ input }, _optional = {}) {
31
37
  if (!input || typeof input !== 'object')
32
- return [];
38
+ return null;
33
39
  const obj = input;
34
- const rawItems = Array.isArray(obj.todos) ? obj.todos : Array.isArray(obj.plan) ? obj.plan : [];
35
- return rawItems
36
- .map((todo) => {
37
- if (!todo || typeof todo !== 'object')
38
- return null;
39
- const record = todo;
40
- const content = typeof record.content === 'string'
41
- ? record.content
42
- : typeof record.step === 'string'
43
- ? record.step
44
- : typeof record.description === 'string'
45
- ? record.description
46
- : typeof record.label === 'string'
47
- ? record.label
48
- : typeof record.text === 'string'
49
- ? record.text
50
- : '';
51
- if (!content)
52
- return null;
53
- return {
54
- content,
55
- status: normalizeTodoStatus(record.status),
56
- activeForm: typeof record.activeForm === 'string'
57
- ? record.activeForm
58
- : typeof record.active_form === 'string'
59
- ? record.active_form
60
- : undefined,
61
- };
62
- })
63
- .filter((todo) => todo !== null);
40
+ if (Array.isArray(obj.todos))
41
+ return { key: 'todos', items: obj.todos };
42
+ if (Array.isArray(obj.plan))
43
+ return { key: 'plan', items: obj.plan };
44
+ return null;
45
+ }
46
+ /** The first of `keys` whose value is a string (even an empty one), else undefined. */
47
+ function firstStringField({ record, keys }, _optional = {}) {
48
+ for (const key of keys) {
49
+ const value = record[key];
50
+ if (typeof value === 'string')
51
+ return value;
52
+ }
53
+ return undefined;
54
+ }
55
+ const TODO_BODY_KEYS = ['content', 'step', 'description', 'label', 'text'];
56
+ const TODO_ACTIVE_FORM_KEYS = ['activeForm', 'active_form'];
57
+ function parseTodoItem({ todo }, _optional = {}) {
58
+ if (!todo || typeof todo !== 'object')
59
+ return null;
60
+ const record = todo;
61
+ const content = firstStringField({ record, keys: TODO_BODY_KEYS });
62
+ if (!content)
63
+ return null;
64
+ return {
65
+ content,
66
+ status: normalizeTodoStatus(record.status),
67
+ activeForm: firstStringField({ record, keys: TODO_ACTIVE_FORM_KEYS }),
68
+ };
64
69
  }
65
70
  /**
66
71
  * Find the most recent plan-tool `tool_use` in a single turn's events and
@@ -68,15 +73,22 @@ export function parseTodoWriteInput({ input }) {
68
73
  * @complexity O(n), scanning from the end.
69
74
  */
70
75
  export function latestTodosFromEvents({ events }) {
71
- if (!events)
72
- return [];
73
- for (let i = events.length - 1; i >= 0; i -= 1) {
74
- const event = events[i];
75
- if (event?.kind !== 'tool_use' || !isTodoWriteToolName({ name: event.name }))
76
- continue;
77
- return parseTodoWriteInput({ input: event.input });
76
+ const event = latestTodoWriteEvent({ events });
77
+ return event ? parseTodoWriteInput({ input: event.input }) : [];
78
+ }
79
+ /** The newest plan-tool `tool_use` in one turn's events, scanning from the end. */
80
+ function latestTodoWriteEvent({ events }, _optional = {}) {
81
+ return events?.findLast((event) => event.kind === 'tool_use' && isTodoWriteToolName({ name: event.name }));
82
+ }
83
+ /** The newest message holding a plan-tool call, with that call; messages are scanned newest first. */
84
+ function latestTodoWriteInMessages({ messages }, _optional = {}) {
85
+ for (let mi = (messages?.length ?? 0) - 1; mi >= 0; mi -= 1) {
86
+ const message = messages[mi];
87
+ const event = latestTodoWriteEvent({ events: message.events });
88
+ if (event)
89
+ return { message, event };
78
90
  }
79
- return [];
91
+ return null;
80
92
  }
81
93
  /** The subset of {@link latestTodosFromEvents} not yet marked `completed`. */
82
94
  export function unfinishedTodosFromEvents({ events }) {
@@ -95,42 +107,20 @@ export function unfinishedTodosFromEvents({ events }) {
95
107
  * update) is far cheaper.
96
108
  */
97
109
  export function latestTodoWriteInputFromMessages({ messages }) {
98
- if (!messages || messages.length === 0)
99
- return null;
100
- for (let mi = messages.length - 1; mi >= 0; mi -= 1) {
101
- const events = messages[mi]?.events;
102
- if (!events || events.length === 0)
103
- continue;
104
- for (let ei = events.length - 1; ei >= 0; ei -= 1) {
105
- const event = events[ei];
106
- if (event?.kind !== 'tool_use')
107
- continue;
108
- if (!isTodoWriteToolName({ name: event.name }))
109
- continue;
110
- return event.input;
111
- }
112
- }
113
- return null;
110
+ return latestTodoWriteInMessages({ messages })?.event.input ?? null;
114
111
  }
115
112
  /** Convenience alias for {@link latestTodoWriteInputFromMessages} matching the `latestTodoWriteInput()` name in the target API. */
116
113
  export const latestTodoWriteInput = latestTodoWriteInputFromMessages;
117
114
  function hasTerminalRunEnded(runStatus, endedAt) {
118
- return (runStatus === 'succeeded' ||
119
- runStatus === 'failed' ||
120
- runStatus === 'canceled' ||
121
- (runStatus === undefined && endedAt !== undefined));
115
+ return isTerminalRunStatus({ status: runStatus }) || (runStatus === undefined && endedAt !== undefined);
122
116
  }
123
117
  function stoppedTodoWriteInput(input) {
124
- if (!input || typeof input !== 'object')
118
+ const list = todoListOf({ input });
119
+ if (!list)
125
120
  return input;
126
- const obj = input;
127
- const key = Array.isArray(obj.todos) ? 'todos' : Array.isArray(obj.plan) ? 'plan' : null;
128
- if (!key)
129
- return input;
130
- const items = obj[key];
131
121
  return {
132
122
  ...input,
133
- [key]: items.map((todo) => {
123
+ [list.key]: list.items.map((todo) => {
134
124
  if (!todo || typeof todo !== 'object')
135
125
  return todo;
136
126
  const record = todo;
@@ -151,25 +141,10 @@ function stoppedTodoWriteInput(input) {
151
141
  * remap of the winning turn's items when the run has ended.
152
142
  */
153
143
  export function latestTodoWriteInputForPinnedCard({ messages }) {
154
- if (!messages || messages.length === 0)
144
+ const found = latestTodoWriteInMessages({ messages });
145
+ if (!found)
155
146
  return null;
156
- for (let mi = messages.length - 1; mi >= 0; mi -= 1) {
157
- const message = messages[mi];
158
- const events = message?.events;
159
- if (!events || events.length === 0)
160
- continue;
161
- for (let ei = events.length - 1; ei >= 0; ei -= 1) {
162
- const event = events[ei];
163
- if (event?.kind !== 'tool_use')
164
- continue;
165
- if (!isTodoWriteToolName({ name: event.name }))
166
- continue;
167
- if (!hasTerminalRunEnded(message.runStatus, message.endedAt)) {
168
- return event.input;
169
- }
170
- return stoppedTodoWriteInput(event.input);
171
- }
172
- }
173
- return null;
147
+ const { message, event } = found;
148
+ return hasTerminalRunEnded(message.runStatus, message.endedAt) ? stoppedTodoWriteInput(event.input) : event.input;
174
149
  }
175
150
  //# sourceMappingURL=todos.js.map
@@ -20,16 +20,16 @@ export function dedupeToolUsesById({ events }) {
20
20
  let deduped = null;
21
21
  for (let i = 0; i < events.length; i += 1) {
22
22
  const event = events[i];
23
- if (event.kind === 'tool_use') {
24
- if (seen.has(event.id)) {
25
- if (!deduped)
26
- deduped = events.slice(0, i);
27
- continue;
28
- }
23
+ if (event.kind !== 'tool_use') {
24
+ deduped?.push(event);
25
+ }
26
+ else if (seen.has(event.id)) {
27
+ deduped ??= events.slice(0, i);
28
+ }
29
+ else {
29
30
  seen.add(event.id);
31
+ deduped?.push(event);
30
32
  }
31
- if (deduped)
32
- deduped.push(event);
33
33
  }
34
34
  return deduped ?? events;
35
35
  }
@@ -46,8 +46,8 @@ const DELEGATED_WRAPPER_PATTERN = /^(?:mcp__.+__)?execute_(?:readonly_)?delegate
46
46
  export function isDelegatedWrapperToolName({ name }) {
47
47
  return DELEGATED_WRAPPER_PATTERN.test(name);
48
48
  }
49
- function wrapperToolId(event) {
50
- if (event.kind !== 'tool_use' || !isDelegatedWrapperToolName({ name: event.name }))
49
+ function wrapperToolId({ event }) {
50
+ if (!isDelegatedWrapperToolName({ name: event.name }))
51
51
  return undefined;
52
52
  const input = event.input;
53
53
  const toolId = typeof input === 'object' && input !== null ? input.toolId : undefined;
@@ -79,9 +79,11 @@ export function foldDelegatedWrapperCalls({ events }) {
79
79
  const claimed = new Set();
80
80
  const folded = new Set();
81
81
  events.forEach((event, i) => {
82
- const toolId = wrapperToolId(event);
82
+ if (event.kind !== 'tool_use')
83
+ return;
84
+ const toolId = wrapperToolId({ event });
83
85
  // A replayed wrapper (same id) must not claim a second canonical row.
84
- if (toolId === undefined || event.kind !== 'tool_use' || folded.has(event.id))
86
+ if (toolId === undefined || folded.has(event.id))
85
87
  return;
86
88
  const match = events.findIndex((other, j) => j > i && !claimed.has(j) && other.kind === 'tool_use' && other.name === toolId);
87
89
  if (match === -1)
@@ -45,18 +45,7 @@ function escapeTranscriptRoleDelimiters(content) {
45
45
  * @complexity O(n) across all messages' events.
46
46
  */
47
47
  function buildPriorRunContextWarning(history, largeToolResultChars, highInputTokenWarningThreshold) {
48
- let highestInputTokens = 0;
49
- let largeToolResults = 0;
50
- for (const message of history) {
51
- for (const event of message.events ?? []) {
52
- if (event.kind === 'usage' && typeof event.inputTokens === 'number') {
53
- highestInputTokens = Math.max(highestInputTokens, event.inputTokens);
54
- }
55
- if (event.kind === 'tool_result' && event.content.length > largeToolResultChars) {
56
- largeToolResults += 1;
57
- }
58
- }
59
- }
48
+ const { highestInputTokens, largeToolResults } = scanPriorRunTelemetry({ history, largeToolResultChars });
60
49
  const notes = [];
61
50
  if (highestInputTokens >= highInputTokenWarningThreshold) {
62
51
  notes.push(`a previous run reported ${highestInputTokens} input tokens`);
@@ -68,6 +57,20 @@ function buildPriorRunContextWarning(history, largeToolResultChars, highInputTok
68
57
  return null;
69
58
  return ['## context warning', `Detected ${notes.join(', ')}.`, 'Keep this turn compact: summarize prior tool output, read large references from temp files, and quote only task-relevant lines.'].join('\n');
70
59
  }
60
+ /** The highest reported input-token count and the number of oversized tool results across all events. */
61
+ function scanPriorRunTelemetry({ history, largeToolResultChars }, _optional = {}) {
62
+ let highestInputTokens = 0;
63
+ let largeToolResults = 0;
64
+ for (const event of history.flatMap((message) => message.events ?? [])) {
65
+ if (event.kind === 'usage' && typeof event.inputTokens === 'number') {
66
+ highestInputTokens = Math.max(highestInputTokens, event.inputTokens);
67
+ }
68
+ if (event.kind === 'tool_result' && event.content.length > largeToolResultChars) {
69
+ largeToolResults += 1;
70
+ }
71
+ }
72
+ return { highestInputTokens, largeToolResults };
73
+ }
71
74
  function scopeHistoryToAgent(history, targetAgentId, isSameAgentFamily) {
72
75
  if (!targetAgentId)
73
76
  return history;
@@ -79,6 +82,43 @@ function scopeHistoryToAgent(history, targetAgentId, isSameAgentFamily) {
79
82
  }
80
83
  return history;
81
84
  }
85
+ /** Scan tag boundaries once, including malformed openers, without retrying an unterminated tag. */
86
+ function questionFormTags({ content }) {
87
+ const matcher = /<(question-form|ask-question)\b|<\/(question-form|ask-question)>/g;
88
+ const tags = [];
89
+ let match;
90
+ while ((match = matcher.exec(content))) {
91
+ const closing = match[2] !== undefined;
92
+ const end = closing ? matcher.lastIndex : content.indexOf('>', matcher.lastIndex) + 1;
93
+ if (!end)
94
+ break;
95
+ tags.push({ name: (match[1] ?? match[2]), start: match.index, end, closing });
96
+ matcher.lastIndex = end;
97
+ }
98
+ return tags;
99
+ }
100
+ /** Find matched form ranges in reverse source order without retrying unmatched openers.
101
+ * Keep the first following close of the same name, matching the former non-greedy replacement.
102
+ * Matching by name (formerly a `\1` backreference) means a `<question-form>…</ask-question>`
103
+ * mismatch is never spliced.
104
+ * @complexity O(n) time and space in content length; each tag and source character is scanned once.
105
+ */
106
+ function questionFormRanges({ content }) {
107
+ const tags = questionFormTags({ content });
108
+ const nextClose = new Map();
109
+ const ranges = [];
110
+ for (let i = tags.length - 1; i >= 0; i--) {
111
+ const tag = tags[i];
112
+ if (tag.closing)
113
+ nextClose.set(tag.name, tag.end);
114
+ else {
115
+ const end = nextClose.get(tag.name);
116
+ if (end !== undefined)
117
+ ranges.push({ start: tag.start, end });
118
+ }
119
+ }
120
+ return ranges;
121
+ }
82
122
  /**
83
123
  * Strip transcript-echo hazards from a prior assistant turn before it goes
84
124
  * back to the agent as history: an already-answered `<question-form>` /
@@ -99,10 +139,16 @@ function scopeHistoryToAgent(history, targetAgentId, isSameAgentFamily) {
99
139
  * passes) plus {@link summarizeArtifactsForTranscript}'s cost.
100
140
  */
101
141
  export function sanitizePriorAssistantTurn({ content }, { persistedArtifactFiles = [] } = {}) {
102
- let sanitized = content.replace(
103
- // `\1` backreference keeps the open/close tag names matched so this never
104
- // splices across a `<question-form>…</ask-question>` mismatch.
105
- /<(question-form|ask-question)\b[^>]*>[\s\S]*?<\/\1>/g, '[question-form was emitted here on a prior turn; the user already answered, see their reply below.]');
142
+ const chunks = [];
143
+ let copiedTo = 0;
144
+ for (const range of questionFormRanges({ content }).reverse()) {
145
+ if (range.start < copiedTo)
146
+ continue;
147
+ chunks.push(content.slice(copiedTo, range.start), '[question-form was emitted here on a prior turn; the user already answered, see their reply below.]');
148
+ copiedTo = range.end;
149
+ }
150
+ chunks.push(content.slice(copiedTo));
151
+ let sanitized = chunks.join('');
106
152
  // Strip ```json (or plain ```) fenced blocks whose body matches the form
107
153
  // schema shape — `"questions": [` is the strongest tell. A generic JSON
108
154
  // snippet without that key is left intact.