@jini-ai/chat 0.6.10 → 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 +10 -0
- 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/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.js +12 -7
- package/dist/react/components/MessageRow.usage.d.ts +9 -1
- package/dist/react/components/MessageRow.usage.js +22 -5
- 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 +1 -1
- 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 +7 -2
- 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 +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
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
|
+
|
|
1
11
|
## 0.6.10
|
|
2
12
|
|
|
3
13
|
### Patch Changes
|
|
@@ -3,6 +3,8 @@ import type { ChatAttachment, ChatMessage } from './messages.js';
|
|
|
3
3
|
export interface AcceptedChatInput {
|
|
4
4
|
id: string;
|
|
5
5
|
text: string;
|
|
6
|
+
/** Absent for legacy/ordinary inputs; consumed only after a CLI UUID receipt. */
|
|
7
|
+
deliveryState?: 'queued' | 'consumed';
|
|
6
8
|
attachments?: ChatAttachment[];
|
|
7
9
|
secretRedaction?: ChatMessage['secretRedaction'];
|
|
8
10
|
/** Wire ids replaced by this receipt remain seen by recall. */
|
|
@@ -50,8 +50,16 @@ function expandQuestionAnswers({ events }, _options = {}) {
|
|
|
50
50
|
}));
|
|
51
51
|
// MOVE: Persisted picks carry values; the original tool input retains their human-readable labels.
|
|
52
52
|
const questions = new Map(events.flatMap(event => event.kind === 'tool_use' ? [[event.id, event.input]] : []));
|
|
53
|
-
|
|
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;
|
|
@@ -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)
|
|
@@ -428,9 +428,9 @@ function renderInline(text) {
|
|
|
428
428
|
else if (mdLink)
|
|
429
429
|
nodes.push(renderMarkdownLink(mdLink, key++));
|
|
430
430
|
else if (bold)
|
|
431
|
-
nodes.push(_jsx("strong", { children: bold.slice(2, -2) }, key++));
|
|
431
|
+
nodes.push(_jsx("strong", { children: renderInline(bold.slice(2, -2)) }, key++));
|
|
432
432
|
else if (italic)
|
|
433
|
-
nodes.push(_jsx("em", { children: italic.slice(1, -1) }, key++));
|
|
433
|
+
nodes.push(_jsx("em", { children: renderInline(italic.slice(1, -1)) }, key++));
|
|
434
434
|
else if (link)
|
|
435
435
|
nodes.push(_jsx("a", { href: link, ...NEW_TAB, children: link }, key++));
|
|
436
436
|
// `whole` is `match[0]` — a successful `RegExpExecArray` always has a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* @module components/McpUiSurfaceCard
|
|
4
4
|
*
|
|
@@ -120,8 +120,10 @@ function PendingSurfaceMirror({ uri, plan, t }) {
|
|
|
120
120
|
}
|
|
121
121
|
/** A closed card: the question's title, when the call gave one, and why it is closed. */
|
|
122
122
|
function ClosedSurface({ call, label }) {
|
|
123
|
-
const
|
|
124
|
-
|
|
123
|
+
const input = call?.input;
|
|
124
|
+
const title = [input?.title, input?.question, input?.prompt]
|
|
125
|
+
.find(value => typeof value === 'string' && value.trim() !== '');
|
|
126
|
+
return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' ? `${title}: ${label}` : label }));
|
|
125
127
|
}
|
|
126
128
|
/** The live card's answer lifecycle: its deadline and delivery notification, using their existing owners. */
|
|
127
129
|
function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnswerText, call }, _options = {}) {
|
|
@@ -144,16 +146,17 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
|
|
|
144
146
|
const closed = single === undefined
|
|
145
147
|
? undefined
|
|
146
148
|
: closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
|
|
147
|
-
const answer =
|
|
149
|
+
const answer = questionAnswerFromResult({ result: call?.result, input: call?.input }) ?? answerText;
|
|
148
150
|
return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: call, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
|
|
149
151
|
}
|
|
150
152
|
/** Rendering priority stays receipt, answer, closure, then the live documents. */
|
|
151
153
|
function SurfaceCardContent({ resources, single, answerRecorded, answer, closed, call, t, sandboxProxyUrl, toolCall, onOpenLink, maxHeight, expiry }) {
|
|
152
|
-
// MOVE: The accepted-input receipt owns its bubble;
|
|
154
|
+
// MOVE: The accepted-input receipt owns its bubble; the closed card retains the question's context.
|
|
153
155
|
if (single !== undefined && answerRecorded)
|
|
154
|
-
return
|
|
156
|
+
return _jsx(ClosedSurface, { call: call, label: answer ?? closed ?? t('Answered') });
|
|
155
157
|
if (single !== undefined && answer !== undefined) {
|
|
156
|
-
|
|
158
|
+
const label = answer === 'Declined' ? t('Declined') : answer;
|
|
159
|
+
return _jsxs(_Fragment, { children: [_jsx(ClosedSurface, { call: call, label: label }), _jsx(MidRunUserMessage, { events: [{ text: label }] })] });
|
|
157
160
|
}
|
|
158
161
|
if (closed !== undefined)
|
|
159
162
|
return _jsx(ClosedSurface, { call: call, label: closed });
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
8
|
import { conversationUsage } from '../../core/turn-usage.js';
|
|
9
|
+
import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
|
|
9
10
|
import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
|
|
10
11
|
const prefersReducedMotion = () => typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
|
|
11
12
|
/** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
|
|
12
13
|
export function useConversationUsage({ messages }, _optional = {}) {
|
|
13
|
-
|
|
14
|
+
// Usage and rendering must see the same answer boundaries, including receipts rebuilt on reload.
|
|
15
|
+
return useMemo(() => conversationUsage({ messages: messages.map(message => message.events === undefined ? message : {
|
|
16
|
+
...message, events: projectAcceptedInputEvents({ events: message.events }),
|
|
17
|
+
}) }), [messages]);
|
|
14
18
|
}
|
|
15
19
|
export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled, reducedMotion = prefersReducedMotion } = {}) {
|
|
16
20
|
const containerRef = useRef(null);
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
|
|
3
3
|
import { MidRunUserMessage, MID_RUN_USER_MESSAGE_EXT_EVENT_NAME } from './MidRunUserMessage.js';
|
|
4
4
|
import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
|
|
5
|
-
import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary } from './MessageRow.usage.js';
|
|
5
|
+
import { usageEndLabel, usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary } from './MessageRow.usage.js';
|
|
6
6
|
/**
|
|
7
7
|
* @module MessageRow
|
|
8
8
|
*
|
|
@@ -111,12 +111,13 @@ function isRunInProgress(runStatus) {
|
|
|
111
111
|
/** One "Done · 6m 29s · $0.41 · total $2.50" line from reported usage, never estimated (the total is
|
|
112
112
|
* left off when `showTotal` is false). Partial usage stays partial; output tokens are available to
|
|
113
113
|
* assistive technology and in the tooltip. */
|
|
114
|
-
function UsageSummary({ usage, showTotal }) {
|
|
114
|
+
function UsageSummary({ usage, showTotal, message, awaitingAnswer }) {
|
|
115
115
|
const t = useT();
|
|
116
|
-
const {
|
|
116
|
+
const label = usageEndLabel({ usage, ...(message === undefined ? {} : { message }), ...(awaitingAnswer === undefined ? {} : { awaitingAnswer }) });
|
|
117
|
+
const { text, title, ariaLabel } = useUsageSummary({ usage }, { showTotal, label });
|
|
117
118
|
if (!text)
|
|
118
119
|
return null;
|
|
119
|
-
return (_jsxs("div", { className: "jini-message-usage", title: title, role: "note", "aria-label": ariaLabel, children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t(
|
|
120
|
+
return (_jsxs("div", { className: "jini-message-usage", title: title, role: "note", "aria-label": ariaLabel, children: [label === 'Done' ? _jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }) : null, t(label), " \u00B7 ", text] }));
|
|
120
121
|
}
|
|
121
122
|
function messageBlocks({ message, rows, registry, content = message.content }, { toolCallDisplay = 'show', includeThinking = false } = {}) {
|
|
122
123
|
const blocks = interleaveMessageBlocks({ events: message.events, content, rows }, { slotOf: (ev) => registry.slot({ name: ev.name, data: ev.data }), includeThinking });
|
|
@@ -150,7 +151,7 @@ function messageAwaitingAnswer({ runStreaming, runInProgress, events }, { isAnsw
|
|
|
150
151
|
}
|
|
151
152
|
function AssistantMessageFooter({ message, usageEvent, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard }, _optional = {}) {
|
|
152
153
|
const t = useT();
|
|
153
|
-
return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent, showTotal: showUsageTotal }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress), isAnsweredCard: isAnsweredCard }) : null] });
|
|
154
|
+
return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent, showTotal: showUsageTotal, message: message, awaitingAnswer: isAwaitingAnswer({ events: message.events }, { isAnsweredCard }) }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress), isAnsweredCard: isAnsweredCard }) : null] });
|
|
154
155
|
}
|
|
155
156
|
/**
|
|
156
157
|
* Quiet, icon-only copy affordance shared by the user- and assistant-message
|
|
@@ -207,8 +208,11 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
|
|
|
207
208
|
const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
|
|
208
209
|
const runInProgress = isRunInProgress(message.runStatus);
|
|
209
210
|
const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, { isAnsweredCard: answeredCards.isAnsweredCard });
|
|
210
|
-
const usage = usageLayout({ usage: messageUsage, interleaved: blocks !== null, runInProgress }, {
|
|
211
|
+
const usage = usageLayout({ usage: messageUsage, interleaved: blocks !== null, runInProgress }, {
|
|
212
|
+
runStreaming: runStreaming && !isTerminalRunStatus({ status: message.runStatus }),
|
|
213
|
+
});
|
|
211
214
|
const footerUsage = showUsageLine ? usage.footer : undefined;
|
|
215
|
+
const lastFinishedReply = !runInProgress && !usage.footer ? usage.replies.findLastIndex(value => value !== undefined) : -1;
|
|
212
216
|
const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
|
|
213
217
|
// Hidden mode reuses the same disclosure and card renderer for unrecovered failures.
|
|
214
218
|
const renderTools = (rows, key) => (toolCallDisplay !== 'show'
|
|
@@ -251,7 +255,8 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
|
|
|
251
255
|
}
|
|
252
256
|
if (block.kind === 'reply-end') {
|
|
253
257
|
const reply = usage.replies[block.index];
|
|
254
|
-
|
|
258
|
+
// A trailing input may have no reply: its predecessor's usage belongs above that input.
|
|
259
|
+
return reply && showUsageLine ? _jsx(UsageSummary, { usage: reply, showTotal: showUsageTotal, ...(block.index === lastFinishedReply ? { message } : {}) }, block.key) : null;
|
|
255
260
|
}
|
|
256
261
|
if (block.kind === 'tools')
|
|
257
262
|
return renderTools(block.rows, block.key);
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ChatMessage } from '../../core/messages.js';
|
|
2
2
|
import { type ChatUsageSummary } from '../../core/turn-usage.js';
|
|
3
|
+
/** A run's terminal state wins over its last model response; interrupted usage never claims success.
|
|
4
|
+
* Missing lifecycle data keeps the legacy completed label. O(n) in terminal notice events. */
|
|
5
|
+
export declare function usageEndLabel({ usage, message, awaitingAnswer }: {
|
|
6
|
+
usage: ChatUsageSummary;
|
|
7
|
+
message?: ChatMessage;
|
|
8
|
+
awaitingAnswer?: boolean;
|
|
9
|
+
}, _options?: {}): string;
|
|
3
10
|
/** A standalone row has one turn; MessageList supplies the full conversation projection. */
|
|
4
11
|
export declare function useMessageUsage({ message, summary }: {
|
|
5
12
|
message: ChatMessage;
|
|
@@ -41,8 +48,9 @@ export declare function usageLineMeetsActions({ footer, terminalNotice, pending,
|
|
|
41
48
|
* the current turn's cost is unknown. `showTotal: false` turns the total off. */
|
|
42
49
|
export declare function useUsageSummary({ usage }: {
|
|
43
50
|
usage: ChatUsageSummary;
|
|
44
|
-
}, { showTotal }?: {
|
|
51
|
+
}, { showTotal, label }?: {
|
|
45
52
|
showTotal?: boolean;
|
|
53
|
+
label?: string;
|
|
46
54
|
}): {
|
|
47
55
|
text: string;
|
|
48
56
|
title: string | undefined;
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
|
+
import { terminalMessageNotice } from '../../core/durable-projection.js';
|
|
2
3
|
import { conversationUsage } from '../../core/turn-usage.js';
|
|
3
4
|
import { formatUsageCost } from '../../core/usage-format.js';
|
|
4
|
-
import {
|
|
5
|
+
import { useI18n } from '../hooks/context.js';
|
|
6
|
+
/** A run's terminal state wins over its last model response; interrupted usage never claims success.
|
|
7
|
+
* Missing lifecycle data keeps the legacy completed label. O(n) in terminal notice events. */
|
|
8
|
+
export function usageEndLabel({ usage, message, awaitingAnswer = false }, _options = {}) {
|
|
9
|
+
const status = message?.runStatus;
|
|
10
|
+
if (status === 'failed')
|
|
11
|
+
return 'Failed';
|
|
12
|
+
if (status === 'canceled') {
|
|
13
|
+
return /stopped/i.test(terminalMessageNotice({ message: message }) ?? '') ? 'Stopped' : 'Canceled';
|
|
14
|
+
}
|
|
15
|
+
if (awaitingAnswer)
|
|
16
|
+
return 'Waiting for your answer';
|
|
17
|
+
const reason = usage.stopReason ?? '';
|
|
18
|
+
if (/error|fail/i.test(reason))
|
|
19
|
+
return 'Failed';
|
|
20
|
+
return /abort|interrupt|cancel|max_tokens|max_tool_turns|length|tool_use/i.test(reason) ? 'Interrupted' : 'Done';
|
|
21
|
+
}
|
|
5
22
|
/** A standalone row has one turn; MessageList supplies the full conversation projection. */
|
|
6
23
|
export function useMessageUsage({ message, summary }, _optional = {}) {
|
|
7
24
|
return useMemo(() => summary ?? conversationUsage({ messages: [message] }).get(message.id), [message, summary]);
|
|
@@ -31,13 +48,13 @@ export function usageLineMeetsActions({ footer, terminalNotice, pending, runInPr
|
|
|
31
48
|
* Each line carries the chat's running total as of that reply, "Done · 16s · $0.39 · total $3.95" (owner,
|
|
32
49
|
* 2026-10-10: keep the total, always shown, even on the first turn). Known totals remain visible when
|
|
33
50
|
* the current turn's cost is unknown. `showTotal: false` turns the total off. */
|
|
34
|
-
export function useUsageSummary({ usage }, { showTotal = true } = {}) {
|
|
35
|
-
const t =
|
|
51
|
+
export function useUsageSummary({ usage }, { showTotal = true, label = 'Done' } = {}) {
|
|
52
|
+
const { t, locale } = useI18n();
|
|
36
53
|
const parts = [usageDuration({ durationMs: usage.durationMs }), usageCost({ usage }, { showTotal })]
|
|
37
54
|
.filter((part) => part !== undefined);
|
|
38
55
|
const text = parts.join(' · ');
|
|
39
|
-
const title = usage.outputTokens === undefined ? undefined : t('{n}
|
|
40
|
-
return { text, title, ariaLabel: title === undefined ? undefined : `${t(
|
|
56
|
+
const title = usage.outputTokens === undefined ? undefined : t('{n} output tokens', { n: usage.outputTokens.toLocaleString(locale) });
|
|
57
|
+
return { text, title, ariaLabel: title === undefined ? undefined : `${t(label)} · ${text} · ${title}` };
|
|
41
58
|
}
|
|
42
59
|
function usageDuration({ durationMs }, _options = {}) {
|
|
43
60
|
if (durationMs === undefined)
|
|
@@ -3,8 +3,10 @@ export interface ToolCallGroupState {
|
|
|
3
3
|
/** Whether the turn's tool cards are showing under the line. Starts closed. */
|
|
4
4
|
open: boolean;
|
|
5
5
|
toggle: () => void;
|
|
6
|
-
/** "Used 3 tools",
|
|
6
|
+
/** "Used 3 tools", or "Running Bash…" while a tool runs. */
|
|
7
7
|
label: string;
|
|
8
|
+
/** The failure suffix, styled independently of the tool count. */
|
|
9
|
+
failureLabel: string | undefined;
|
|
8
10
|
running: boolean;
|
|
9
11
|
failed: boolean;
|
|
10
12
|
}
|
|
@@ -15,7 +15,8 @@ export function useToolCallGroup({ rows }, _optional = {}) {
|
|
|
15
15
|
return {
|
|
16
16
|
open,
|
|
17
17
|
toggle,
|
|
18
|
-
label: toolGroupLabel({ summary, t }),
|
|
18
|
+
label: toolGroupLabel({ summary: summary.kind === 'done' ? { ...summary, failed: 0 } : summary, t }),
|
|
19
|
+
failureLabel: summary.kind === 'done' && summary.failed > 0 ? t('{count} failed', { count: summary.failed }) : undefined,
|
|
19
20
|
running: summary.kind === 'running',
|
|
20
21
|
failed: summary.kind === 'done' && summary.failed > 0,
|
|
21
22
|
};
|
|
@@ -10,6 +10,6 @@ import { Icon } from './Icon.js';
|
|
|
10
10
|
export function ToolCallGroup({ rows, renderRow, failureOnly = false }) {
|
|
11
11
|
const group = useToolCallGroup({ rows });
|
|
12
12
|
const t = useT();
|
|
13
|
-
return (_jsxs("div", { className: "jini-tool-group", "data-failed": group.failed ? 'true' : undefined, children: [failureOnly ? _jsxs("span", { className: "jini-tool-group__label", children: [t('A step failed'), " "] }) : null, _jsxs("button", { type: "button", className: "jini-tool-group__toggle", "aria-expanded": group.open, onClick: group.toggle, children: [_jsx("span", { className: `jini-tool-group__label${group.running ? ' shimmer-text' : ''}`, children: failureOnly ? t('Show') : group.label }), _jsx(Icon, { name: group.open ? 'chevron-down' : 'chevron-right', size: 12 })] }), group.open ? _jsx("div", { className: "jini-message-tools", children: rows.map(renderRow) }) : null] }));
|
|
13
|
+
return (_jsxs("div", { className: "jini-tool-group", "data-failed": group.failed ? 'true' : undefined, children: [failureOnly ? _jsxs("span", { className: "jini-tool-group__label", children: [t('A step failed'), " "] }) : null, _jsxs("button", { type: "button", className: "jini-tool-group__toggle", "aria-expanded": group.open, onClick: group.toggle, children: [_jsx("span", { className: `jini-tool-group__label${group.running ? ' shimmer-text' : ''}`, children: failureOnly ? t('Show') : group.label }), !failureOnly && group.failureLabel ? _jsxs("span", { className: "jini-tool-group__failed", children: [" \u00B7 ", group.failureLabel] }) : null, _jsx(Icon, { name: group.open ? 'chevron-down' : 'chevron-right', size: 12 })] }), group.open ? _jsx("div", { className: "jini-message-tools", children: rows.map(renderRow) }) : null] }));
|
|
14
14
|
}
|
|
15
15
|
//# sourceMappingURL=ToolCallGroup.js.map
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
*
|
|
16
16
|
* ## Why it resolves an outcome instead of throwing
|
|
17
17
|
*
|
|
18
|
-
* The pane acts differently on each outcome and must never drop the text: `'unsupported'`
|
|
19
|
-
*
|
|
18
|
+
* The pane acts differently on each outcome and must never drop the text: `'unsupported'` queues
|
|
19
|
+
* the whole turn; `'not-running'` queues behind the
|
|
20
20
|
* ending run; `'failed'` retains the draft for retry. A 404 means
|
|
21
21
|
* the daemon never saw this run (a host-side run such as a BYOK API turn), so it is `'unsupported'`
|
|
22
|
-
* too: no live process to steer
|
|
22
|
+
* too: no live process to steer. Accepted inputs retain their fallback copy until consumed.
|
|
23
23
|
*/
|
|
24
24
|
import { type UserTextRedactionOptions } from '../../../core/user-text-redaction.js';
|
|
25
25
|
import type { DeliverMidRunMessage } from './types.js';
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
*
|
|
16
16
|
* ## Why it resolves an outcome instead of throwing
|
|
17
17
|
*
|
|
18
|
-
* The pane acts differently on each outcome and must never drop the text: `'unsupported'`
|
|
19
|
-
*
|
|
18
|
+
* The pane acts differently on each outcome and must never drop the text: `'unsupported'` queues
|
|
19
|
+
* the whole turn; `'not-running'` queues behind the
|
|
20
20
|
* ending run; `'failed'` retains the draft for retry. A 404 means
|
|
21
21
|
* the daemon never saw this run (a host-side run such as a BYOK API turn), so it is `'unsupported'`
|
|
22
|
-
* too: no live process to steer
|
|
22
|
+
* too: no live process to steer. Accepted inputs retain their fallback copy until consumed.
|
|
23
23
|
*/
|
|
24
24
|
import { redactUserText } from '../../../core/user-text-redaction.js';
|
|
25
25
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -31,8 +31,11 @@ async function conflictDelivery(response) {
|
|
|
31
31
|
}
|
|
32
32
|
/** Maps the route's response onto an outcome. Only 202 is a delivery. */
|
|
33
33
|
async function deliveryFor(response) {
|
|
34
|
-
if (response.status === 202)
|
|
35
|
-
|
|
34
|
+
if (response.status === 202) {
|
|
35
|
+
const body = await response.json().catch(() => null);
|
|
36
|
+
const id = body?.id;
|
|
37
|
+
return typeof id === 'string' && id.length > 0 ? { delivery: 'delivered', id } : 'delivered';
|
|
38
|
+
}
|
|
36
39
|
if (response.status === 409)
|
|
37
40
|
return conflictDelivery(response);
|
|
38
41
|
return response.status === 404 ? 'unsupported' : 'failed';
|
|
@@ -8,6 +8,35 @@ import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../.
|
|
|
8
8
|
import { useComposer } from '../../../hooks/useComposer.js';
|
|
9
9
|
import { useConversation, } from '../../../hooks/useConversation.js';
|
|
10
10
|
import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
|
|
11
|
+
/** Filter during render so a consumed receipt followed by end in the same batch cannot flush twice.
|
|
12
|
+
* Legacy host receipts can replace optimistic IDs; the accepted-input owner retains their aliases.
|
|
13
|
+
* @complexity O(e + q) time and space for receipt events and locally queued inputs.
|
|
14
|
+
*/
|
|
15
|
+
function unconsumedQueuedInputs({ inputs, messages }) {
|
|
16
|
+
const consumed = new Set();
|
|
17
|
+
for (const message of messages) {
|
|
18
|
+
for (const event of projectAcceptedInputEvents({ events: message.events ?? [] })) {
|
|
19
|
+
const receipt = acceptedInputEvent({ event });
|
|
20
|
+
if (receipt?.data.deliveryState !== 'consumed')
|
|
21
|
+
continue;
|
|
22
|
+
for (const input of [receipt.data, ...(receipt.data.inputs ?? [])]) {
|
|
23
|
+
consumed.add(input.id);
|
|
24
|
+
for (const alias of input.aliases ?? [])
|
|
25
|
+
consumed.add(alias);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return inputs.filter(input => !consumed.has(input.liveMessageId ?? input.id));
|
|
30
|
+
}
|
|
31
|
+
/** The conversation copies run events onto its messages one effect after streaming stops, so a
|
|
32
|
+
* consumed receipt can still be missing from the transcript when the send blocker clears. A
|
|
33
|
+
* live-accepted copy therefore waits until the run's own message reaches a terminal status. */
|
|
34
|
+
function awaitingRunReceipts({ inputs, messages }) {
|
|
35
|
+
if (!inputs.some(input => input.liveMessageId !== undefined))
|
|
36
|
+
return false;
|
|
37
|
+
const status = messages.at(-1)?.runStatus;
|
|
38
|
+
return status === 'queued' || status === 'running';
|
|
39
|
+
}
|
|
11
40
|
/** The stand-in prompt for a turn that carries only attachments. */
|
|
12
41
|
const ATTACHMENTS_ONLY_PROMPT = 'Review the attached file(s).';
|
|
13
42
|
/**
|
|
@@ -149,11 +178,7 @@ export function useChatPane(options) {
|
|
|
149
178
|
const [activeUploadCount, setActiveUploadCount] = useState(0);
|
|
150
179
|
const [attachmentError, setAttachmentError] = useState(null);
|
|
151
180
|
// MOVE: Keep the signal with each sanitized input; re-redaction cannot detect the original paste.
|
|
152
|
-
const [
|
|
153
|
-
const queuedTurn = useMemo(() => queuedInputs.reduce((queued, input) => appendQueuedTurn({
|
|
154
|
-
queued, next: { text: input.text, secretRedacted: input.secretRedaction?.secretRedacted ?? false,
|
|
155
|
-
count: input.secretRedaction?.count ?? 0 },
|
|
156
|
-
}), null), [queuedInputs]);
|
|
181
|
+
const [storedQueuedInputs, setQueuedInputs] = useState([]);
|
|
157
182
|
// MOVE: Uploads belong to the input that submitted them, even if a later message is queued first.
|
|
158
183
|
const queuedUploadOwnersRef = useRef(new Map());
|
|
159
184
|
// A send during upload resumes once pixels arrive; a rejected live delivery waits for run end.
|
|
@@ -221,6 +246,16 @@ export function useChatPane(options) {
|
|
|
221
246
|
validateAttachments: options.validateAttachments,
|
|
222
247
|
} }));
|
|
223
248
|
const activity = resolveChatPaneActivity(selectedAgent, conversation);
|
|
249
|
+
const queuedInputs = useMemo(() => unconsumedQueuedInputs({ inputs: storedQueuedInputs, messages: conversation.messages }), [storedQueuedInputs, conversation.messages]);
|
|
250
|
+
const queuedTurn = useMemo(() => queuedInputs.reduce((queued, input) => appendQueuedTurn({
|
|
251
|
+
queued, next: { text: input.text, secretRedacted: input.secretRedaction?.secretRedacted ?? false,
|
|
252
|
+
count: input.secretRedaction?.count ?? 0 },
|
|
253
|
+
}), null), [queuedInputs]);
|
|
254
|
+
// Release consumed text/files locally as well as hiding them; next-turn fallback uses this same projection.
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (queuedInputs.length !== storedQueuedInputs.length)
|
|
257
|
+
setQueuedInputs(queuedInputs);
|
|
258
|
+
}, [queuedInputs, storedQueuedInputs.length]);
|
|
224
259
|
useEffect(() => {
|
|
225
260
|
options.onActivityChange?.(activity);
|
|
226
261
|
}, [activity, options.onActivityChange]);
|
|
@@ -363,7 +398,7 @@ export function useChatPane(options) {
|
|
|
363
398
|
}
|
|
364
399
|
}, [composer, options.conversationId, options.uploadAttachments]);
|
|
365
400
|
/** Acceptance is the only writer of local recall and optimistic in-run bubbles. */
|
|
366
|
-
const recordAcceptedInput = useCallback(({ inputs, bubble },
|
|
401
|
+
const recordAcceptedInput = useCallback(({ inputs, bubble }, { receipt = 'optimistic' } = {}) => {
|
|
367
402
|
composer.history?.record({ inputs });
|
|
368
403
|
submittedComposerRef.current = composer;
|
|
369
404
|
if (bubble === undefined)
|
|
@@ -372,8 +407,9 @@ export function useChatPane(options) {
|
|
|
372
407
|
const last = messages.at(-1);
|
|
373
408
|
if (last?.role !== 'assistant')
|
|
374
409
|
return messages;
|
|
375
|
-
const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text
|
|
376
|
-
|
|
410
|
+
const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text,
|
|
411
|
+
...(inputs[0].deliveryState ? { deliveryState: inputs[0].deliveryState } : {}) },
|
|
412
|
+
inputs, ...bubble }, { receipt });
|
|
377
413
|
return [...messages.slice(0, -1), { ...last,
|
|
378
414
|
events: projectAcceptedInputEvents({ events: [...(last.events ?? []), event] }) }];
|
|
379
415
|
});
|
|
@@ -495,24 +531,37 @@ export function useChatPane(options) {
|
|
|
495
531
|
if (inputs === undefined && latestDraftRef.current === submittedDraft)
|
|
496
532
|
latestComposerRef.current.setDraft('');
|
|
497
533
|
}, []);
|
|
534
|
+
/** HTTP acceptance retains fallback ownership; only an actual consumption receipt removes it. */
|
|
535
|
+
const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId }, _options = {}) => {
|
|
536
|
+
const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
|
|
537
|
+
...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
|
|
538
|
+
const id = typeof outcome === 'object' ? outcome.id : accepted[0].id;
|
|
539
|
+
// A known HTTP id is already authoritative. Do not let equal text reconcile an unrelated
|
|
540
|
+
// wire receipt into this bubble; legacy hosts alone still need optimistic reconciliation.
|
|
541
|
+
const retained = accepted.map((input, index) => ({ ...input,
|
|
542
|
+
...(index === 0 ? { id, aliases: [...(input.aliases ?? []), input.id] } : {}),
|
|
543
|
+
liveMessageId: id, deliveryState: 'queued',
|
|
544
|
+
}));
|
|
545
|
+
queuedConversationIdRef.current = options.conversationId;
|
|
546
|
+
setQueuedInputs(queued => [...queued, ...retained]);
|
|
547
|
+
recordAcceptedInput({ inputs: retained,
|
|
548
|
+
bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
|
|
549
|
+
}, [options.conversationId, recordAcceptedInput]);
|
|
498
550
|
const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
|
|
499
551
|
if (outcome === 'failed') {
|
|
500
552
|
restoreQueuedDraft({ inputs, text: turn.text, attachments });
|
|
501
553
|
setTypedAnswerNotice('failed');
|
|
502
554
|
return;
|
|
503
555
|
}
|
|
504
|
-
if (outcome === 'delivered') {
|
|
505
|
-
|
|
506
|
-
bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } });
|
|
556
|
+
if (outcome === 'delivered' || typeof outcome === 'object') {
|
|
557
|
+
retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId });
|
|
507
558
|
clearDeliveredDraft({ inputs, submittedDraft });
|
|
508
559
|
consumeDeliveredAttachments({ attachments });
|
|
509
560
|
return;
|
|
510
561
|
}
|
|
511
562
|
enqueueTurn({ turn, attachments, ...(inputs === undefined ? {} : { inputs }) });
|
|
512
563
|
clearDeliveredDraft({ inputs, submittedDraft });
|
|
513
|
-
|
|
514
|
-
conversation.cancel();
|
|
515
|
-
}, [conversation.cancel, enqueueTurn, recordAcceptedInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
|
|
564
|
+
}, [enqueueTurn, retainAcceptedMidRunInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
|
|
516
565
|
// A delivered steer belongs to the existing run; unsupported turns use the same accepted queue.
|
|
517
566
|
// A failed delivery keeps the working draft so retry cannot silently become another run.
|
|
518
567
|
const sendIntoRunningTurn = useCallback(async ({ deliver, runId, turn, inputs, attachments = composer.attachments }, _options = {}) => {
|
|
@@ -544,7 +593,7 @@ export function useChatPane(options) {
|
|
|
544
593
|
if (uploadPending)
|
|
545
594
|
queuedForUploadRef.current = queuedTurn === null || queuedForUploadRef.current;
|
|
546
595
|
// MOVE: The existing queue owns its files; a later steer must join it to preserve send order.
|
|
547
|
-
const target = uploadPending ||
|
|
596
|
+
const target = uploadPending || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
|
|
548
597
|
deliver: options.deliverMidRunMessage,
|
|
549
598
|
blocker: sendBlocker,
|
|
550
599
|
attachmentCount: composer.attachments.length,
|
|
@@ -565,6 +614,7 @@ export function useChatPane(options) {
|
|
|
565
614
|
enqueueTurn,
|
|
566
615
|
options.deliverMidRunMessage,
|
|
567
616
|
queuedTurn,
|
|
617
|
+
queuedInputs,
|
|
568
618
|
sendBlocker,
|
|
569
619
|
sendIntoRunningTurn,
|
|
570
620
|
sendPrompt,
|
|
@@ -659,7 +709,7 @@ export function useChatPane(options) {
|
|
|
659
709
|
deliver: options.deliverMidRunMessage,
|
|
660
710
|
blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
|
|
661
711
|
}) : null;
|
|
662
|
-
if (sendBlocker !== null
|
|
712
|
+
if (target === null && (sendBlocker !== null || awaitingRunReceipts({ inputs: queuedInputs, messages: conversation.messages })))
|
|
663
713
|
return;
|
|
664
714
|
queuedForUploadRef.current = false;
|
|
665
715
|
const inputs = queuedInputs;
|
|
@@ -712,7 +762,9 @@ export function useChatPane(options) {
|
|
|
712
762
|
send,
|
|
713
763
|
sendPrompt,
|
|
714
764
|
queuedPrompt: queuedTurn?.text ?? null,
|
|
715
|
-
|
|
765
|
+
// Accepted live messages already have queued bubbles in the transcript. Their private fallback
|
|
766
|
+
// copy must not create a second composer row whose Cancel button cannot revoke daemon input.
|
|
767
|
+
queuedMessages: queuedInputs.filter(input => input.liveMessageId === undefined),
|
|
716
768
|
cancelQueuedMessage,
|
|
717
769
|
cancelQueued,
|
|
718
770
|
typedAnswerNotice,
|
|
@@ -176,11 +176,16 @@ export type DeliverTypedAnswer = ((input: {
|
|
|
176
176
|
};
|
|
177
177
|
/**
|
|
178
178
|
* What happened to a message sent while a run streams (see {@link ChatPaneProps.deliverMidRunMessage}):
|
|
179
|
-
* `'delivered'` — the live agent
|
|
179
|
+
* `{delivery:'delivered', id}` — accepted by the live agent; keep the local copy until an id-matched
|
|
180
|
+
* consumed event. Legacy hosts may still return `'delivered'`; their receipts reconcile optimistic ids.
|
|
181
|
+
* `'unsupported'` — this agent cannot
|
|
180
182
|
* take input mid-run (it reads one prompt per turn); `'not-running'` — the run has no live process
|
|
181
183
|
* any more (it is ending); `'failed'` — the host could not tell (network error, unexpected status).
|
|
182
184
|
*/
|
|
183
|
-
export type MidRunMessageDelivery = 'delivered' | 'unsupported' | 'not-running' | 'failed'
|
|
185
|
+
export type MidRunMessageDelivery = 'delivered' | 'unsupported' | 'not-running' | 'failed' | {
|
|
186
|
+
readonly delivery: 'delivered';
|
|
187
|
+
readonly id: string;
|
|
188
|
+
};
|
|
184
189
|
/** Delivers a complete turn to the live agent; unsupported attachments must reject the whole turn. */
|
|
185
190
|
export type DeliverMidRunMessage = (input: {
|
|
186
191
|
runId: string;
|
|
@@ -17,22 +17,29 @@ function lastIndexWhere({ events, match }, _optional = {}) {
|
|
|
17
17
|
return index;
|
|
18
18
|
return -1;
|
|
19
19
|
}
|
|
20
|
-
/** One
|
|
21
|
-
function
|
|
22
|
-
if (event.kind === "status")
|
|
23
|
-
return [event.label, event.detail ?? ""];
|
|
24
|
-
if (event.kind !== "raw")
|
|
25
|
-
return [];
|
|
20
|
+
/** One complete stdout line's contribution to executor diagnostics. */
|
|
21
|
+
function diagnosticLine({ line }, _optional = {}) {
|
|
26
22
|
try {
|
|
27
|
-
const result = JSON.parse(
|
|
23
|
+
const result = JSON.parse(line);
|
|
28
24
|
// Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
|
|
29
25
|
if (result?.type === "result" && result.is_error === false)
|
|
30
26
|
return [];
|
|
27
|
+
// A subagent report can discuss billing/auth in completed work; it is not executor evidence.
|
|
28
|
+
if (result?.type === "system" && result.subtype === "task_notification")
|
|
29
|
+
return [];
|
|
31
30
|
}
|
|
32
31
|
catch {
|
|
33
32
|
// Non-JSON stdout can still contain the executor's failure reason.
|
|
34
33
|
}
|
|
35
|
-
return [
|
|
34
|
+
return [line];
|
|
35
|
+
}
|
|
36
|
+
/** A saved raw event may hold several JSONL lines; filter each before failure classification. */
|
|
37
|
+
function diagnosticLines({ event }, _optional = {}) {
|
|
38
|
+
if (event.kind === "status")
|
|
39
|
+
return [event.label, event.detail ?? ""];
|
|
40
|
+
if (event.kind !== "raw")
|
|
41
|
+
return [];
|
|
42
|
+
return event.line.split(/\r?\n/).flatMap(line => diagnosticLine({ line }));
|
|
36
43
|
}
|
|
37
44
|
/** Retryability is independent of session resumability. Inspect executor diagnostics, never
|
|
38
45
|
* answer text or tool results: those can quote unrelated failures from completed work. Old
|
|
@@ -118,8 +125,8 @@ function savedWork({ run }, _optional = {}) {
|
|
|
118
125
|
return saved;
|
|
119
126
|
}
|
|
120
127
|
/** A dead attempt continues now; a fresh queued one waits (see the caller's comment). */
|
|
121
|
-
function continuesNow({ run,
|
|
122
|
-
return
|
|
128
|
+
function continuesNow({ run, trigger, now }, _optional = {}) {
|
|
129
|
+
return trigger === "attempt-failed" || now - run.attemptStartedAt >= 10_000;
|
|
123
130
|
}
|
|
124
131
|
/** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
|
|
125
132
|
* Each recovery decision logs one `logger.info` line (default console) of run metadata. */
|
|
@@ -193,37 +200,66 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
193
200
|
if (exclusion(run))
|
|
194
201
|
return { decision: await finalize(run, STOPPED_NOTICE, { canceled: true }) };
|
|
195
202
|
const probe = await probeAttempt({ run, trigger, liveRunId });
|
|
203
|
+
// HTTP/storage awaits can cross cancellation, settlement, or an attempt-generation change.
|
|
204
|
+
// Re-read before attaching or applying diagnostics to the probed attempt.
|
|
205
|
+
const current = await reload(run);
|
|
206
|
+
if (typeof current === "string")
|
|
207
|
+
return { probe, decision: current };
|
|
196
208
|
if (probe === "live")
|
|
197
|
-
return { probe, decision: await reattach(
|
|
209
|
+
return { probe, decision: await reattach(current) };
|
|
210
|
+
if (probe === "uncertain") {
|
|
211
|
+
// Transport uncertainty is not proof of death and spends no restart/time budget. The
|
|
212
|
+
// existing watcher waits and probes again; even an expired budget cannot kill a live child.
|
|
213
|
+
await reattach(current);
|
|
214
|
+
return { probe, decision: "waiting" };
|
|
215
|
+
}
|
|
216
|
+
return decideDead(current, trigger);
|
|
217
|
+
}
|
|
218
|
+
async function decideDead(run, trigger) {
|
|
219
|
+
const probe = "dead";
|
|
198
220
|
// A pending human answer takes precedence over executor exit diagnostics.
|
|
199
|
-
if (
|
|
200
|
-
return { probe, decision: await resolveMissing(run,
|
|
221
|
+
if (awaitingAnswer({ run }))
|
|
222
|
+
return { probe, decision: await resolveMissing(run, trigger) };
|
|
201
223
|
// Diagnostics decide only the fate of an attempt that is no longer running. Read before the
|
|
202
224
|
// probe, raw stdout quoting a tool description ("OAuth-authenticated") canceled a live turn
|
|
203
225
|
// as "Not logged in" (luvira, 2026-10-08).
|
|
204
226
|
const diagnostics = attemptDiagnostics({ run });
|
|
205
227
|
const failure = attemptFailure({ diagnostics });
|
|
206
|
-
const decision = failure === "retryable" ? await resolveMissing(run,
|
|
228
|
+
const decision = failure === "retryable" ? await resolveMissing(run, trigger) : await finalizeFailure({ run, failure, diagnostics });
|
|
207
229
|
return { probe, failure, decision };
|
|
208
230
|
}
|
|
209
|
-
async function resolveMissing(run,
|
|
231
|
+
async function resolveMissing(run, trigger) {
|
|
210
232
|
await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: true }, {});
|
|
211
|
-
const timed = await
|
|
233
|
+
const timed = await reload(run);
|
|
234
|
+
return typeof timed === "string" ? timed : resolveDead(timed, trigger);
|
|
235
|
+
}
|
|
236
|
+
/** The saved row after an await, or the outcome that ends this decision. O(1) store reads. */
|
|
237
|
+
async function reload(run) {
|
|
238
|
+
const current = await ports.store.load({ messageId: run.messageId }, {});
|
|
239
|
+
if (!current)
|
|
240
|
+
return "gone";
|
|
212
241
|
// A probe only speaks about the generation it inspected. Never apply its dead result to
|
|
213
242
|
// a newer attempt committed by another caller while this one was awaiting storage/HTTP.
|
|
214
|
-
if (
|
|
243
|
+
if (current.runId !== run.runId)
|
|
215
244
|
return "superseded";
|
|
245
|
+
if (current.message.runStatus !== "queued" && current.message.runStatus !== "running")
|
|
246
|
+
return "terminal";
|
|
247
|
+
if (exclusion(current))
|
|
248
|
+
return finalize(current, STOPPED_NOTICE, { canceled: true });
|
|
249
|
+
return current;
|
|
250
|
+
}
|
|
251
|
+
async function resolveDead(run, trigger) {
|
|
216
252
|
// A dead process cannot resume its question. End this turn with the existing card intact,
|
|
217
253
|
// rather than minting an attempt that runs preflight and asks the same question again.
|
|
218
|
-
if (
|
|
219
|
-
return finalize(
|
|
220
|
-
const notice = budgetNotice(
|
|
254
|
+
if (awaitingAnswer({ run }))
|
|
255
|
+
return finalize(run, AWAITING_ANSWER_NOTICE);
|
|
256
|
+
const notice = budgetNotice(run, ports.now());
|
|
221
257
|
if (notice)
|
|
222
|
-
return finalize(run, notice, { detail: notice === EXHAUSTED_NOTICE ? exhaustedDetail({ run
|
|
258
|
+
return finalize(run, notice, { detail: notice === EXHAUSTED_NOTICE ? exhaustedDetail({ run }) : undefined });
|
|
223
259
|
// A queued attempt may be between durable acceptance and the daemon's start transaction.
|
|
224
260
|
// Give that idempotent launch time to appear before a 404 can advance its generation.
|
|
225
|
-
if (continuesNow({ run,
|
|
226
|
-
return continueDead(
|
|
261
|
+
if (continuesNow({ run, trigger, now: ports.now() }))
|
|
262
|
+
return continueDead(run);
|
|
227
263
|
ports.attach(run, {});
|
|
228
264
|
return "waiting";
|
|
229
265
|
}
|
|
@@ -28,8 +28,8 @@ export declare function toMessage(row: MessageRow): ChatMessage;
|
|
|
28
28
|
* @param scope the isolation predicate, resolved from the host's authentication. For a guest,
|
|
29
29
|
* `ownerId` must already be a hash of the session key (`tenant-scope.ts`).
|
|
30
30
|
* @param optional Optional core Clock for deterministic host/tests; defaults to system wall time.
|
|
31
|
-
* @complexity each method is one to four indexed statements;
|
|
32
|
-
* `messages` O(messages in the conversation).
|
|
31
|
+
* @complexity each method is one to four indexed statements; conversation summaries scan their
|
|
32
|
+
* messages and embedded events in SQL; `messages` is O(messages in the conversation).
|
|
33
33
|
*/
|
|
34
34
|
export declare function createSqlChatStore<DB extends ChatDatabase>({ kernel, scope: inputScope }: {
|
|
35
35
|
kernel: StorageKernel<DB>;
|
package/dist/store/sql/store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { sql } from 'kysely';
|
|
|
13
13
|
import { createSystemClock } from '@jini-ai/core/primitives';
|
|
14
14
|
import { isUniqueViolation } from '@jini-ai/db/kernel';
|
|
15
15
|
import { redactUserMessage } from '../../core/user-text-redaction.js';
|
|
16
|
+
import { MID_RUN_USER_MESSAGE_EVENT_NAME } from '../../core/turn-usage.js';
|
|
16
17
|
import { ChatStoreError } from '../errors.js';
|
|
17
18
|
import { copyScope, atStoreBoundary, runChatQuery } from './boundary.js';
|
|
18
19
|
import { pageLimit, decodeCursor, encodeCursor } from './cursor.js';
|
|
@@ -113,8 +114,8 @@ function toHistoryMessage(row, { includeToolEvents }) {
|
|
|
113
114
|
* @param scope the isolation predicate, resolved from the host's authentication. For a guest,
|
|
114
115
|
* `ownerId` must already be a hash of the session key (`tenant-scope.ts`).
|
|
115
116
|
* @param optional Optional core Clock for deterministic host/tests; defaults to system wall time.
|
|
116
|
-
* @complexity each method is one to four indexed statements;
|
|
117
|
-
* `messages` O(messages in the conversation).
|
|
117
|
+
* @complexity each method is one to four indexed statements; conversation summaries scan their
|
|
118
|
+
* messages and embedded events in SQL; `messages` is O(messages in the conversation).
|
|
118
119
|
*/
|
|
119
120
|
export function createSqlChatStore({ kernel, scope: inputScope }, { clock = createSystemClock(), ...redactionOptions } = {}) {
|
|
120
121
|
const scope = copyScope({ scope: inputScope });
|
|
@@ -122,6 +123,13 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
|
|
|
122
123
|
// The single subset boundary: DB must contain both required table definitions. Extra host tables
|
|
123
124
|
// stay on the exact borrowed kernel; its transaction context is never projected or recreated.
|
|
124
125
|
const run = (body) => runChatQuery({ kernel: kernel, body: body });
|
|
126
|
+
// Count embedded user messages in SQL: list/get still return summaries, never whole transcripts.
|
|
127
|
+
// Writers store `JSON.stringify` output (no whitespace, as appendedEventsJson also relies on), so
|
|
128
|
+
// each mid-run event holds this exact text once; text inside a JSON string has escaped quotes and
|
|
129
|
+
// cannot match, and invalid JSON stays readable (it counts what it holds, never throws). Plain
|
|
130
|
+
// length/replace run on SQLite and PostgreSQL 14+, unlike `IS JSON` (PostgreSQL 16+).
|
|
131
|
+
const needle = `"name":"${MID_RUN_USER_MESSAGE_EVENT_NAME}"`;
|
|
132
|
+
const midRunCount = sql `((length(coalesce(m.events_json, '')) - length(replace(coalesce(m.events_json, ''), ${sql.lit(needle)}, ''))) / ${sql.lit(needle.length)})`;
|
|
125
133
|
/** This scope's conversations, projected as {@link ConversationRow}s with their message count. */
|
|
126
134
|
function ownedConversations(db) {
|
|
127
135
|
return db
|
|
@@ -135,7 +143,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
|
|
|
135
143
|
"c.expires_at",
|
|
136
144
|
eb
|
|
137
145
|
.selectFrom("ai_chat_messages as m")
|
|
138
|
-
.select((
|
|
146
|
+
.select(sql `coalesce(sum(1 + case when m.role = 'assistant' then ${midRunCount} else 0 end), 0)`.as("n"))
|
|
139
147
|
.whereRef("m.conversation_id", "=", "c.id")
|
|
140
148
|
.as("message_count"),
|
|
141
149
|
])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/chat",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -196,7 +196,7 @@
|
|
|
196
196
|
"registry": "https://registry.npmjs.org"
|
|
197
197
|
},
|
|
198
198
|
"dependencies": {
|
|
199
|
-
"@jini-ai/core": "^0.6.
|
|
199
|
+
"@jini-ai/core": "^0.6.11"
|
|
200
200
|
},
|
|
201
201
|
"peerDependencies": {
|
|
202
202
|
"react": "^18.3.0 || ^19.0.0",
|
|
@@ -303,13 +303,13 @@
|
|
|
303
303
|
"pg": "8.23.0",
|
|
304
304
|
"@ag-ui/core": "0.0.58",
|
|
305
305
|
"fast-check": "4.3.0",
|
|
306
|
-
"@jini-ai/
|
|
307
|
-
"@jini-ai/
|
|
308
|
-
"@jini-ai/
|
|
309
|
-
"@jini-ai/
|
|
310
|
-
"@jini-ai/
|
|
311
|
-
"@jini-ai/
|
|
312
|
-
"@jini-ai/
|
|
306
|
+
"@jini-ai/db": "0.6.11",
|
|
307
|
+
"@jini-ai/agent-runtime": "0.6.11",
|
|
308
|
+
"@jini-ai/protocol": "^0.6.11",
|
|
309
|
+
"@jini-ai/core": "^0.6.11",
|
|
310
|
+
"@jini-ai/diagnostics": "^0.6.11",
|
|
311
|
+
"@jini-ai/ui": "^0.6.11",
|
|
312
|
+
"@jini-ai/agentic": "^0.6.11"
|
|
313
313
|
},
|
|
314
314
|
"typesVersions": {
|
|
315
315
|
"*": {
|