@jini-ai/chat 0.6.7 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -1
- package/dist/core/accepted-input.d.ts +2 -0
- package/dist/core/accepted-input.js +34 -3
- package/dist/core/assistant-client/durable-run-subscription.d.ts +4 -2
- package/dist/core/assistant-client/durable-run-subscription.js +36 -7
- package/dist/core/assistant-client/transport.js +3 -1
- package/dist/core/persistence/title.d.ts +8 -16
- package/dist/core/persistence/title.js +20 -63
- package/dist/core/question-answer.js +2 -2
- package/dist/core/run-events/entry.d.ts +1 -1
- package/dist/core/run-events/entry.js +2 -1
- package/dist/core/turn-usage.js +19 -2
- package/dist/react/components/Markdown.js +2 -2
- package/dist/react/components/McpUiSurfaceCard.js +10 -7
- package/dist/react/components/MessageList.hooks.js +5 -1
- package/dist/react/components/MessageRow.d.ts +1 -1
- package/dist/react/components/MessageRow.js +41 -24
- package/dist/react/components/MessageRow.usage.d.ts +9 -1
- package/dist/react/components/MessageRow.usage.js +22 -5
- package/dist/react/components/ToolCallGroup.d.ts +4 -3
- package/dist/react/components/ToolCallGroup.hooks.d.ts +3 -1
- package/dist/react/components/ToolCallGroup.hooks.js +2 -1
- package/dist/react/components/ToolCallGroup.js +7 -5
- package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +3 -3
- package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -5
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +69 -17
- package/dist/react/features/chat-pane/types.d.ts +9 -3
- package/dist/react/tool-call-display.d.ts +12 -6
- package/dist/react/tool-call-display.js +42 -15
- package/dist/server/durable-runs/recover.js +59 -23
- package/dist/store/sql/store.d.ts +2 -2
- package/dist/store/sql/store.js +11 -3
- package/package.json +15 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,137 @@
|
|
|
1
|
+
## 0.6.11
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 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".
|
|
6
|
+
- 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.
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @jini-ai/core@0.6.11
|
|
10
|
+
|
|
11
|
+
## 0.6.10
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- f9d9894: Hide recovered tool failures when tool calls are hidden. Keep unrecovered failures in one compact, localized disclosure per reply, preserving their existing details.
|
|
16
|
+
- 8ff5653: Make the chat-pane transcript readable from outside the component — a must-have for an AI (or an
|
|
17
|
+
e2e harness) verifying that commands it gave the pane (create a form, add a user, change a
|
|
18
|
+
permission) actually happened.
|
|
19
|
+
|
|
20
|
+
Before this, `<ChatPane>` fully encapsulated its conversation state: `useConversation()`'s
|
|
21
|
+
`messages` never left the component, `ChatPaneProps` had no callback that reported the live message
|
|
22
|
+
list, and the rendered transcript (`MessageList`/`MessageRow`/`ToolCard`) carried zero
|
|
23
|
+
`data-agent-element` tags — unlike every other interactive surface in this app's reference pages.
|
|
24
|
+
|
|
25
|
+
- `ChatPaneProps.onMessagesChange?: (messages: ChatMessage[]) => void` — fires with the full message
|
|
26
|
+
array on every change, mirroring the existing `onActivityChange` pattern. Each `ChatMessage`
|
|
27
|
+
already carries its `events` array (`tool_use`/`tool_result`), so a subscriber gets complete,
|
|
28
|
+
structured tool-call data for free — no DOM parsing needed.
|
|
29
|
+
- `data-agent-element="chat-transcript"` (role `list`) on the message-list container, and
|
|
30
|
+
`data-agent-element="chat-message-<id>"` (role `region`) on every message row, so `page.*` DOM
|
|
31
|
+
tools can enumerate the transcript the same way they read everything else in this app.
|
|
32
|
+
- `ToolCard` now wraps whatever it renders (any of its dozen built-in card variants, or a host's own
|
|
33
|
+
custom-registered renderer) in one `data-agent-element="tool-call-<use.id>"` (role `region`)
|
|
34
|
+
element — added at the single dispatch point every branch already funnels through, so no
|
|
35
|
+
individual card variant's own markup changed. The collapsed-by-default accordion detail (args/
|
|
36
|
+
result JSON) sits inside this tagged element, not gated behind expanding it: `textContent` (what
|
|
37
|
+
the `page.*` DOM driver actually reads) is layout-agnostic, so the full detail is readable whether
|
|
38
|
+
or not anything has been clicked open.
|
|
39
|
+
|
|
40
|
+
- 3fe4d14: Add compatibility regression fixtures for legacy inline image results, media-only saved events and delegated echo notes; runtime behavior is unchanged.
|
|
41
|
+
- 8ff5653: Add `createDaemonAttachmentUploader`, so composer drag-and-drop is one line instead of 160.
|
|
42
|
+
|
|
43
|
+
`ChatPane` has always gated its drop target and file picker on `uploadAttachments` being supplied — but
|
|
44
|
+
supplying it meant writing the client half yourself: a bounded-concurrency worker pool that preserves
|
|
45
|
+
input order, per-turn quota accounting, abort and timeout plumbing, and cleanup of files that already
|
|
46
|
+
landed when a later one fails. Every host would write the same ~160 lines, and the ordering and
|
|
47
|
+
cleanup details are exactly the ones that get skipped.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
<ChatPane
|
|
51
|
+
transport={transport}
|
|
52
|
+
uploadAttachments={createDaemonAttachmentUploader(daemonUrl)}
|
|
53
|
+
/>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That talks to `@jini-ai/http-kit`'s `POST`/`DELETE /api/attachments`. Pass `''` when a dev-server proxy
|
|
57
|
+
already forwards `/api` from the page's own origin. `maxAttachmentBytes`, `maxAttachmentCount`,
|
|
58
|
+
`maxBatchBytes`, `timeoutMs`, and `concurrency` are all configurable and all default to the daemon side's
|
|
59
|
+
own limits, so the client's early rejection matches what the daemon would have said anyway.
|
|
60
|
+
|
|
61
|
+
Client-side quotas are a courtesy, never the boundary — telling a user their 400 MB video is too large
|
|
62
|
+
before the browser streams it, not after. The daemon re-derives every one of them.
|
|
63
|
+
|
|
64
|
+
Two behavioral notes for anyone porting hand-rolled code onto this:
|
|
65
|
+
|
|
66
|
+
- **The request always sends `content-type: application/octet-stream`**, not `file.type`. The daemon
|
|
67
|
+
sniffs the real kind from the bytes and ignores the header, so forwarding the browser's guess buys
|
|
68
|
+
nothing — and breaks real uploads: a dropped `.json` file arrives as `application/json`, which an
|
|
69
|
+
app-wide `express.json()` on the daemon then claims, draining the request stream before the upload
|
|
70
|
+
route can read a byte.
|
|
71
|
+
- **Batch accounting is per uploader instance**, not module-global, so two panes pointed at two daemons
|
|
72
|
+
cannot consume each other's per-turn quota. Reservations are rolled back on failure, so a retry of a
|
|
73
|
+
failed turn is not refused for quota it never actually used.
|
|
74
|
+
|
|
75
|
+
- 72ea04d: Folded `@jini-ai/renderers-react` into `@jini-ai/ui` as an independently-importable, tree-shakeable
|
|
76
|
+
`./renderers` subpath, and retired the package.
|
|
77
|
+
|
|
78
|
+
Real (non-comment) imports of `@jini-ai/renderers-react` anywhere in the repo were exactly 3 files,
|
|
79
|
+
2 symbols, all inside `@jini-ai/ui`'s own `features/html-viewer` — `openSandboxedPreviewInNewTab`
|
|
80
|
+
and `useSandboxBridge`. `@jini-ai/ui` was already the package's only real consumer, so a standalone
|
|
81
|
+
package bought nothing, the same reasoning that folded `@jini-ai/ui-core` into `./core` and
|
|
82
|
+
`@jini-ai/composio` into `@jini-ai/admin`. The whole `src/**` tree (registry, renderers, srcDoc
|
|
83
|
+
host, annotation-canvas, preview-modal-shell, i18n) moves to `packages/ui/src/renderers/` as one
|
|
84
|
+
`git mv`, unchanged in shape; `html-viewer`'s two consumers switch from the package-qualified
|
|
85
|
+
import to a relative one, since they're now in the same package.
|
|
86
|
+
|
|
87
|
+
**The `@jini-ai/renderers-react → @jini-ai/chat` dependency was dropped first, before the move,
|
|
88
|
+
not after.** `registry.ts` imported `ArtifactManifest`/`inferLegacyManifest` from `@jini-ai/chat`,
|
|
89
|
+
and `types.ts` re-exported five manifest types from there. `@jini-ai/chat` already depends on
|
|
90
|
+
`@jini-ai/ui`, so folding renderers-react into `ui` unchanged would have created a real `ui ↔ chat`
|
|
91
|
+
package cycle. The five manifest types (`ArtifactManifest`, `ArtifactKind`, `ArtifactRendererId`,
|
|
92
|
+
`ArtifactExportKind`, `ArtifactStatus`) are now defined locally in `types.ts`, copied verbatim from
|
|
93
|
+
`@jini-ai/chat`'s `src/core/util/types.ts` — a small duplicated-type cost, accepted because a
|
|
94
|
+
renderer package must not depend on a chat package.
|
|
95
|
+
|
|
96
|
+
**`resolveArtifactManifest` no longer infers a legacy manifest from a file's name/extension** — a
|
|
97
|
+
real, disclosed API behavior change. It now returns `file.manifest ?? null`. Manifest inference
|
|
98
|
+
(guessing kind/renderer from `.md`/`.html`/`.svg`/`.tsx`, etc.) is a caller concern that depends on
|
|
99
|
+
a product's own naming conventions, not something a generic renderer registry should do on a
|
|
100
|
+
caller's behalf; a caller that still wants that inference should resolve a manifest for the file
|
|
101
|
+
itself (see `@jini-ai/chat`'s `inferLegacyManifest`) before handing it to this registry. Verified
|
|
102
|
+
zero external callers of `RendererRegistry`/`resolveArtifactManifest`/`createDefaultRendererRegistry`
|
|
103
|
+
anywhere in Jini or Tovu before making this change — `@jini-ai/chat`'s own `RendererRegistry` is an
|
|
104
|
+
unrelated, locally-defined class in `artifact-types.ts`, and `@jini-ai/artifacts`' own
|
|
105
|
+
`resolveArtifactManifest` is an unrelated 3-argument function; both are same-name coincidences, not
|
|
106
|
+
real dependents. The four bundled renderers (`HtmlRenderer`, `MarkdownRenderer`, `SvgRenderer`,
|
|
107
|
+
`ReactComponentRenderer`) gate `canRender` on a resolvable manifest before falling back to any
|
|
108
|
+
`file.kind`/extension heuristic of their own, so this ripples into every test that exercised
|
|
109
|
+
default-registry routing via a bare file name with no explicit `manifest` — all updated to attach
|
|
110
|
+
one, with the extension-inference-specific assertions flipped to assert the removed behavior no
|
|
111
|
+
longer fires. No test was weakened or deleted: every changed assertion now tests the new,
|
|
112
|
+
documented contract, or restores a manifest to keep testing what it always meant to test.
|
|
113
|
+
|
|
114
|
+
**`@jini-ai/renderers-react` is retired, not versioned forward.** It was published (0.1.2), and
|
|
115
|
+
`packages/renderers-react` is deleted from this workspace. Deleting the source does not unpublish
|
|
116
|
+
anything, so whoever runs the next release should follow up with:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
npm deprecate @jini-ai/renderers-react "Merged into @jini-ai/ui; import from '@jini-ai/ui/renderers' instead."
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`@jini-ai/chat`'s dead `@jini-ai/renderers-react` dependency (declared, never imported — its own
|
|
123
|
+
`react/artifact-types.ts` defines a local placeholder registry instead, per its
|
|
124
|
+
`TODO(renderers-react)` header, left untouched here as out of scope) is removed from its
|
|
125
|
+
`package.json`, hence the accompanying patch bump.
|
|
126
|
+
|
|
127
|
+
- 0c247a8: Fix visitor chat streaming announcements, resumed focus, frame batching, persistence and lazy mount/retry handling. Document the additive A2uiSurfaceCard registry prop for host validation and rendering.
|
|
128
|
+
- e16a907: Clear floating-point noise in displayed costs, reconcile resumed counter resets and revisited sessions, and expose reported output tokens to assistive technology.
|
|
129
|
+
- ea8d883: Release modal semantics when focus leaves chat, announce bounded rendered reply text, and retry opening without reloading mounted assets.
|
|
130
|
+
- Updated dependencies [22f0619]
|
|
131
|
+
- Updated dependencies [82ed2d3]
|
|
132
|
+
- Updated dependencies [5a3de86]
|
|
133
|
+
- @jini-ai/core@0.6.10
|
|
134
|
+
|
|
1
135
|
## 0.6.7 — 2026-10-22
|
|
2
136
|
|
|
3
137
|
- 2026-10-22: Memory fixes (durable run store reads its own row, ledger parses events once), shared settings segmented control, Slack token rule restored, coverage.
|
|
@@ -118,7 +252,6 @@
|
|
|
118
252
|
- Correct renderer priority and opt-in stylesheet documentation; retain the layout rationale with neutral provenance.
|
|
119
253
|
- BREAKING: replace the local finalizer Clock with core Clock.nowMs(); core/React helpers and ext-event slot-key callbacks take required objects.
|
|
120
254
|
|
|
121
|
-
|
|
122
255
|
- BREAKING: ChatStore methods use required argument objects; paging options move to the second bag. ChatHistoryMaintenance and ChatStoreFactory follow the same convention.
|
|
123
256
|
- BREAKING: SQLite/Postgres/PGlite factories use core `Clock` through `{ clock }` rather than `{ now }`; system-time defaults and SQL behavior are preserved.
|
|
124
257
|
|
|
@@ -208,5 +341,6 @@
|
|
|
208
341
|
- @jini-ai/agentic@0.1.1
|
|
209
342
|
|
|
210
343
|
### C2 storage ownership
|
|
344
|
+
|
|
211
345
|
- Added `./store/legacy` and `./store/legacy/sqlite`; preserved sync local project chat CRUD and schema.
|
|
212
346
|
- Retained the original owner-isolation suite on the concern adapter after draining the old package.
|
|
@@ -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
|
-
|
|
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
|
-
},
|
|
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 },
|
|
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,
|
|
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
|
-
|
|
74
|
-
|
|
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;
|
|
@@ -103,7 +103,9 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
|
|
|
103
103
|
* `events_json` is the sole durable record of why a run died (exit code, signal, the CLI's own
|
|
104
104
|
* stderr) once the daemon's in-memory event log is gone, and the pane renders it on reload — so
|
|
105
105
|
* dropping the row trades a history bug for a forensics hole. The skip belongs here, on the read
|
|
106
|
-
* side.
|
|
106
|
+
* side. The transcript's hidden-tool mode likewise filters only recovered tool failures and keeps
|
|
107
|
+
* unrecovered failures behind a compact disclosure; it never deletes this durable evidence.
|
|
108
|
+
* See {@link historyForTranscript} and `assistant-chats.ts`'s `persistableMessages`.
|
|
107
109
|
*/
|
|
108
110
|
function isAnsweredAssistantTurn(message) {
|
|
109
111
|
return message.runStatus === undefined || message.runStatus === "succeeded";
|
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
},
|
|
49
|
-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
32
|
-
* enormous token, so it
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 },
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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)
|
|
@@ -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
|
|
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
|
|
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
|
/**
|
package/dist/core/turn-usage.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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)
|