@mono-agent/web 0.20.8 → 0.20.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/contracts.d.ts +31 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +29 -0
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +6 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +16 -5
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +40 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +256 -4
- package/dist/store.js.map +1 -1
- package/package.json +4 -4
- package/webapp/dist/assets/index-DG1ELH22.css +1 -0
- package/webapp/dist/assets/index-j6VTEsJ5.js +155 -0
- package/webapp/dist/index.html +2 -2
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-BN-Of26p.css +0 -1
- package/webapp/dist/assets/index-q4yPuGvw.js +0 -152
package/dist/store.js
CHANGED
|
@@ -8,7 +8,117 @@ import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_AT
|
|
|
8
8
|
import { WebConsoleError } from "./errors.js";
|
|
9
9
|
import { webPushPreview } from "./push-preview.js";
|
|
10
10
|
import { prepareWebStatePaths } from "./state-paths.js";
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The searchable text of one message row, derived in SQL so the index cannot
|
|
13
|
+
* drift from `parts_json`: every write path in this store goes through the
|
|
14
|
+
* triggers below rather than remembering to maintain a second copy.
|
|
15
|
+
*
|
|
16
|
+
* Only `text` parts are indexed. Reasoning is the agent's working-out and tool
|
|
17
|
+
* payloads are machine JSON; both would drown a search of what was actually
|
|
18
|
+
* said in a conversation.
|
|
19
|
+
*
|
|
20
|
+
* The two highlight sentinels are stripped here rather than trusted to be
|
|
21
|
+
* absent: a message that quotes one would otherwise come back in a snippet as
|
|
22
|
+
* an unbalanced marker and corrupt the client's highlighting.
|
|
23
|
+
*/
|
|
24
|
+
const MESSAGE_SEARCH_BODY_SQL = `
|
|
25
|
+
SELECT replace(replace(group_concat(json_extract(value, '$.text'), ' '), char(2), ''), char(3), '')
|
|
26
|
+
FROM json_each(%SOURCE%.parts_json)
|
|
27
|
+
WHERE json_extract(value, '$.type') = 'text'
|
|
28
|
+
AND json_extract(value, '$.text') IS NOT NULL`;
|
|
29
|
+
const messageSearchBody = (source) => MESSAGE_SEARCH_BODY_SQL.replaceAll("%SOURCE%", source);
|
|
30
|
+
/**
|
|
31
|
+
* `unicode61 remove_diacritics 2` folds accents, so an unaccented query still
|
|
32
|
+
* finds accented prose. The write triggers are guarded on `json_valid` because
|
|
33
|
+
* an unindexed message is one missing search hit, while a failed insert would
|
|
34
|
+
* be a lost message.
|
|
35
|
+
*/
|
|
36
|
+
const MESSAGE_SEARCH_REINDEX_SQL = `
|
|
37
|
+
DELETE FROM message_search WHERE rowid = old.rowid;
|
|
38
|
+
INSERT INTO message_search(rowid, body)
|
|
39
|
+
SELECT new.rowid, (${messageSearchBody("new")});`;
|
|
40
|
+
/**
|
|
41
|
+
* A streaming answer is rewritten every ~50 ms, and re-extracting a large
|
|
42
|
+
* message's text on each snapshot costs several times the row write itself
|
|
43
|
+
* (measured at ~6x, and ~23 ms per snapshot on the largest real messages). A
|
|
44
|
+
* running message is therefore left out of the index entirely — including at
|
|
45
|
+
* insert, because a cron run is inserted with real prose while still running,
|
|
46
|
+
* and indexing that body once would freeze it: the thread would keep matching
|
|
47
|
+
* narration it no longer contains. It is swept in when it settles, by either
|
|
48
|
+
* statement shape, since some paths write `parts_json` and `status` together and
|
|
49
|
+
* others write only one of them. `reindexUnsettledMessages` closes the remaining
|
|
50
|
+
* hole, a process that dies mid-turn.
|
|
51
|
+
*/
|
|
52
|
+
const MESSAGE_SEARCH_SCHEMA_SQL = `
|
|
53
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS message_search USING fts5(
|
|
54
|
+
body,
|
|
55
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
56
|
+
);
|
|
57
|
+
CREATE TRIGGER IF NOT EXISTS message_search_insert
|
|
58
|
+
AFTER INSERT ON messages
|
|
59
|
+
WHEN json_valid(new.parts_json) AND new.status <> 'running' BEGIN
|
|
60
|
+
INSERT INTO message_search(rowid, body)
|
|
61
|
+
SELECT new.rowid, (${messageSearchBody("new")});
|
|
62
|
+
END;
|
|
63
|
+
CREATE TRIGGER IF NOT EXISTS message_search_update
|
|
64
|
+
AFTER UPDATE OF parts_json ON messages
|
|
65
|
+
WHEN json_valid(new.parts_json) AND new.status <> 'running' BEGIN
|
|
66
|
+
${MESSAGE_SEARCH_REINDEX_SQL}
|
|
67
|
+
END;
|
|
68
|
+
CREATE TRIGGER IF NOT EXISTS message_search_settle
|
|
69
|
+
AFTER UPDATE OF status ON messages
|
|
70
|
+
WHEN json_valid(new.parts_json) AND old.status = 'running' AND new.status <> 'running' BEGIN
|
|
71
|
+
${MESSAGE_SEARCH_REINDEX_SQL}
|
|
72
|
+
END;
|
|
73
|
+
CREATE TRIGGER IF NOT EXISTS message_search_delete
|
|
74
|
+
AFTER DELETE ON messages BEGIN
|
|
75
|
+
DELETE FROM message_search WHERE rowid = old.rowid;
|
|
76
|
+
END;`;
|
|
77
|
+
/**
|
|
78
|
+
* Messages still marked running carry whatever text the last snapshot left, and
|
|
79
|
+
* the triggers deliberately skipped them. Sweeping them at open means a turn
|
|
80
|
+
* killed by a crash is searchable rather than silently missing forever.
|
|
81
|
+
*/
|
|
82
|
+
const MESSAGE_SEARCH_UNSETTLED_SQL = `
|
|
83
|
+
DELETE FROM message_search
|
|
84
|
+
WHERE rowid IN (SELECT rowid FROM messages WHERE status = 'running');
|
|
85
|
+
INSERT INTO message_search(rowid, body)
|
|
86
|
+
SELECT m.rowid, (${messageSearchBody("m")})
|
|
87
|
+
FROM messages m
|
|
88
|
+
WHERE m.status = 'running' AND json_valid(m.parts_json);`;
|
|
89
|
+
const MESSAGE_SEARCH_BACKFILL_SQL = `
|
|
90
|
+
DELETE FROM message_search;
|
|
91
|
+
INSERT INTO message_search(rowid, body)
|
|
92
|
+
SELECT m.rowid, (${messageSearchBody("m")})
|
|
93
|
+
FROM messages m
|
|
94
|
+
WHERE json_valid(m.parts_json);`;
|
|
95
|
+
/** Rows scanned before ranking cuts off; bounds the cost of a one-letter term. */
|
|
96
|
+
const MESSAGE_SEARCH_SCAN_LIMIT = 400;
|
|
97
|
+
export const WEB_THREAD_SEARCH_MAX = 50;
|
|
98
|
+
/** Below this a query matches almost everything, so it is not worth running. */
|
|
99
|
+
export const WEB_THREAD_SEARCH_MIN_QUERY = 2;
|
|
100
|
+
/**
|
|
101
|
+
* Wrap each match inside a returned snippet so the client can highlight it.
|
|
102
|
+
* Control characters, and stripped from the indexed body above, so a snippet's
|
|
103
|
+
* markers are always the ones `snippet()` added.
|
|
104
|
+
*/
|
|
105
|
+
export const WEB_SEARCH_HIGHLIGHT_OPEN = "\u0002";
|
|
106
|
+
export const WEB_SEARCH_HIGHLIGHT_CLOSE = "\u0003";
|
|
107
|
+
/**
|
|
108
|
+
* An FTS5 MATCH expression for a typed query. Each token is quoted (so the
|
|
109
|
+
* user's punctuation can never be read as FTS operator syntax) and given a
|
|
110
|
+
* prefix `*` so typing narrows results as you go. Tokens are ANDed: adding a
|
|
111
|
+
* word should cut the result list, not grow it.
|
|
112
|
+
*/
|
|
113
|
+
export function messageSearchMatchExpression(raw) {
|
|
114
|
+
const tokens = raw.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
115
|
+
return tokens.length === 0 ? undefined : tokens.map((token) => `"${token}"*`).join(" ");
|
|
116
|
+
}
|
|
117
|
+
/** Escape the LIKE wildcards so a literal `%` in a title search stays literal. */
|
|
118
|
+
export function escapeLikeTerm(raw) {
|
|
119
|
+
return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
|
|
120
|
+
}
|
|
121
|
+
const WEB_STORAGE_SCHEMA_VERSION = 9;
|
|
12
122
|
const MAX_REVISIONS_PER_THREAD = 1_000;
|
|
13
123
|
export const WEB_THREAD_PAGE_MAX = 200;
|
|
14
124
|
export const WEB_MESSAGE_PAGE_MAX = 100;
|
|
@@ -53,6 +163,9 @@ export class WebStore {
|
|
|
53
163
|
store.recoverInterruptedTurns();
|
|
54
164
|
store.recoverLiveInputs();
|
|
55
165
|
store.recoverWebPushDeliveries();
|
|
166
|
+
// After recovery, so anything the recovery settled is already indexed by
|
|
167
|
+
// its own trigger and this only sweeps what genuinely stayed running.
|
|
168
|
+
store.reindexUnsettledMessages();
|
|
56
169
|
return store;
|
|
57
170
|
}
|
|
58
171
|
catch (error) {
|
|
@@ -919,6 +1032,108 @@ export class WebStore {
|
|
|
919
1032
|
: {}),
|
|
920
1033
|
};
|
|
921
1034
|
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Conversations of one agent whose title or message prose matches `query`.
|
|
1037
|
+
*
|
|
1038
|
+
* The sidebar filter this replaces could only see the threads already loaded
|
|
1039
|
+
* into the browser, so anything past the first page was unfindable. This runs
|
|
1040
|
+
* against the whole store instead: the FTS index ranks message hits by bm25
|
|
1041
|
+
* and returns a highlighted snippet, and a separate title pass catches
|
|
1042
|
+
* conversations named after something never said inside them.
|
|
1043
|
+
*/
|
|
1044
|
+
searchThreads(input) {
|
|
1045
|
+
if (this.getAgent(input.sourceId) === undefined) {
|
|
1046
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
1047
|
+
}
|
|
1048
|
+
const query = input.query.trim();
|
|
1049
|
+
const limit = boundedPageLimit(input.limit, WEB_THREAD_SEARCH_MAX);
|
|
1050
|
+
const match = query.length < WEB_THREAD_SEARCH_MIN_QUERY
|
|
1051
|
+
? undefined
|
|
1052
|
+
: messageSearchMatchExpression(query);
|
|
1053
|
+
if (match === undefined)
|
|
1054
|
+
return { hits: [], truncated: false };
|
|
1055
|
+
const messageRows = this.database.prepare(`
|
|
1056
|
+
SELECT m.thread_id AS thread_id,
|
|
1057
|
+
snippet(message_search, 0, ?, ?, char(8230), 12) AS snippet,
|
|
1058
|
+
bm25(message_search) AS rank
|
|
1059
|
+
FROM message_search
|
|
1060
|
+
JOIN messages m ON m.rowid = message_search.rowid
|
|
1061
|
+
JOIN threads t ON t.id = m.thread_id
|
|
1062
|
+
WHERE message_search MATCH ? AND t.source_id = ?
|
|
1063
|
+
ORDER BY rank
|
|
1064
|
+
LIMIT ?
|
|
1065
|
+
`).all(WEB_SEARCH_HIGHLIGHT_OPEN, WEB_SEARCH_HIGHLIGHT_CLOSE, match, input.sourceId, MESSAGE_SEARCH_SCAN_LIMIT + 1);
|
|
1066
|
+
// Ranked rows arrive best-first, so the first row seen for a thread is that
|
|
1067
|
+
// thread's best snippet and its rank. The extra probe row exists only to
|
|
1068
|
+
// detect truncation and must not inflate a count or admit a thread.
|
|
1069
|
+
const byThread = new Map();
|
|
1070
|
+
for (const row of messageRows.slice(0, MESSAGE_SEARCH_SCAN_LIMIT)) {
|
|
1071
|
+
const existing = byThread.get(row.thread_id);
|
|
1072
|
+
if (existing === undefined) {
|
|
1073
|
+
byThread.set(row.thread_id, {
|
|
1074
|
+
...(row.snippet === null || row.snippet.length === 0 ? {} : { snippet: row.snippet }),
|
|
1075
|
+
rank: row.rank,
|
|
1076
|
+
matches: 1,
|
|
1077
|
+
});
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
byThread.set(row.thread_id, { ...existing, matches: existing.matches + 1 });
|
|
1081
|
+
}
|
|
1082
|
+
// A conversation the user renamed after what it is about may not repeat that
|
|
1083
|
+
// word in any message, so titles are matched separately — as a substring,
|
|
1084
|
+
// because a title is short enough to scan and short enough to type part of.
|
|
1085
|
+
const titleRows = this.database.prepare(`
|
|
1086
|
+
SELECT t.id AS id FROM threads t
|
|
1087
|
+
WHERE t.source_id = ? AND t.title LIKE '%' || ? || '%' ESCAPE '\\'
|
|
1088
|
+
ORDER BY t.updated_at DESC, t.id DESC
|
|
1089
|
+
LIMIT ?
|
|
1090
|
+
`).all(input.sourceId, escapeLikeTerm(query), limit + 1);
|
|
1091
|
+
const titleMatches = new Set(titleRows.slice(0, limit).map((row) => row.id));
|
|
1092
|
+
const rankedIds = [...byThread.entries()]
|
|
1093
|
+
.map(([threadId, hit]) => ({ threadId, ...hit }))
|
|
1094
|
+
.sort((left, right) => left.rank - right.rank)
|
|
1095
|
+
.map((hit) => hit.threadId)
|
|
1096
|
+
.filter((threadId) => !titleMatches.has(threadId));
|
|
1097
|
+
// Title hits lead, because naming a conversation is a deliberate act. But
|
|
1098
|
+
// titles are auto-derived from first prompts, so a common word can match
|
|
1099
|
+
// more of them than fit one page — and letting titles take every slot would
|
|
1100
|
+
// silently return this feature to the title-only search it replaces. Half
|
|
1101
|
+
// the page is therefore reserved for ranked message hits whenever there are
|
|
1102
|
+
// any, and each side takes the other's unused room.
|
|
1103
|
+
const titleIds = [...titleMatches];
|
|
1104
|
+
const titleBudget = rankedIds.length === 0
|
|
1105
|
+
? limit
|
|
1106
|
+
: Math.max(limit - rankedIds.length, Math.ceil(limit / 2));
|
|
1107
|
+
const ordering = [
|
|
1108
|
+
...titleIds.slice(0, titleBudget),
|
|
1109
|
+
...rankedIds,
|
|
1110
|
+
...titleIds.slice(titleBudget),
|
|
1111
|
+
];
|
|
1112
|
+
const selectThread = this.database.prepare(threadSelectSql("WHERE t.id = ?"));
|
|
1113
|
+
const hits = [];
|
|
1114
|
+
for (const threadId of ordering) {
|
|
1115
|
+
if (hits.length >= limit)
|
|
1116
|
+
break;
|
|
1117
|
+
const row = selectThread.get(threadId);
|
|
1118
|
+
if (row === undefined)
|
|
1119
|
+
continue;
|
|
1120
|
+
const hit = byThread.get(threadId);
|
|
1121
|
+
hits.push({
|
|
1122
|
+
thread: this.mapThread(row),
|
|
1123
|
+
messageMatches: hit?.matches ?? 0,
|
|
1124
|
+
titleMatch: titleMatches.has(threadId),
|
|
1125
|
+
...(hit?.snippet === undefined ? {} : { snippet: hit.snippet }),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
hits,
|
|
1130
|
+
// Both queries fetch one row past their cap, so this reports a real cut
|
|
1131
|
+
// rather than firing on an exact fill.
|
|
1132
|
+
truncated: messageRows.length > MESSAGE_SEARCH_SCAN_LIMIT
|
|
1133
|
+
|| titleRows.length > limit
|
|
1134
|
+
|| ordering.length > hits.length,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
922
1137
|
resolveThreadId(id) {
|
|
923
1138
|
let resolved = id;
|
|
924
1139
|
const seen = new Set();
|
|
@@ -2200,6 +2415,7 @@ export class WebStore {
|
|
|
2200
2415
|
);
|
|
2201
2416
|
CREATE INDEX IF NOT EXISTS push_deliveries_due
|
|
2202
2417
|
ON push_deliveries(status, next_attempt_at, created_at);
|
|
2418
|
+
${MESSAGE_SEARCH_SCHEMA_SQL}
|
|
2203
2419
|
`);
|
|
2204
2420
|
if (versionRow.user_version === 1) {
|
|
2205
2421
|
const columns = this.database.prepare("PRAGMA table_info(threads)").all();
|
|
@@ -2216,6 +2432,12 @@ export class WebStore {
|
|
|
2216
2432
|
this.database.exec("ALTER TABLE cron_overviews ADD COLUMN jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1))");
|
|
2217
2433
|
}
|
|
2218
2434
|
}
|
|
2435
|
+
// The search index derives from parts_json, so an existing database has
|
|
2436
|
+
// to be swept once. Migration writes no message, so the triggers above
|
|
2437
|
+
// cannot have fired yet; the clear-then-insert is a no-op on a fresh
|
|
2438
|
+
// database and idempotent if the sweep is ever re-run.
|
|
2439
|
+
if (versionRow.user_version < 9)
|
|
2440
|
+
this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
|
|
2219
2441
|
if (migrating)
|
|
2220
2442
|
this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
|
|
2221
2443
|
}
|
|
@@ -2391,6 +2613,7 @@ export class WebStore {
|
|
|
2391
2613
|
"push_subscriptions",
|
|
2392
2614
|
"push_events",
|
|
2393
2615
|
"push_deliveries",
|
|
2616
|
+
"message_search",
|
|
2394
2617
|
]);
|
|
2395
2618
|
const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
|
|
2396
2619
|
for (const table of tables)
|
|
@@ -2408,6 +2631,16 @@ export class WebStore {
|
|
|
2408
2631
|
}
|
|
2409
2632
|
}
|
|
2410
2633
|
}
|
|
2634
|
+
/**
|
|
2635
|
+
* Index the messages the streaming gate skipped and nothing settled — the
|
|
2636
|
+
* residue of a process that died mid-turn. Bounded to whatever is still
|
|
2637
|
+
* marked running, which is at most one message per interrupted thread.
|
|
2638
|
+
*/
|
|
2639
|
+
reindexUnsettledMessages() {
|
|
2640
|
+
this.transaction(() => {
|
|
2641
|
+
this.database.exec(MESSAGE_SEARCH_UNSETTLED_SQL);
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2411
2644
|
recoverInterruptedTurns() {
|
|
2412
2645
|
const active = this.listActiveTurnIds();
|
|
2413
2646
|
for (const turnId of active) {
|
|
@@ -3294,6 +3527,7 @@ function applyEvent(parts, event) {
|
|
|
3294
3527
|
if (event.type === "tool_call_completed") {
|
|
3295
3528
|
const status = event.isError === true ? "failed" : "complete";
|
|
3296
3529
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3530
|
+
const executionMs = canonicalExecutionMs(event.executionMs);
|
|
3297
3531
|
const subagent = subagentOf(event);
|
|
3298
3532
|
if (subagent !== undefined) {
|
|
3299
3533
|
const group = ensureSubagentPart(parts, subagent);
|
|
@@ -3301,7 +3535,7 @@ function applyEvent(parts, event) {
|
|
|
3301
3535
|
replaceSubagentPart(parts, withEventHistoryUpdate({
|
|
3302
3536
|
...group,
|
|
3303
3537
|
status,
|
|
3304
|
-
...(
|
|
3538
|
+
...(executionMs === undefined ? {} : { executionMs }),
|
|
3305
3539
|
...(subagent.costUsd === undefined ? {} : { costUsd: subagent.costUsd }),
|
|
3306
3540
|
}, historyUpdate));
|
|
3307
3541
|
return;
|
|
@@ -3312,6 +3546,7 @@ function applyEvent(parts, event) {
|
|
|
3312
3546
|
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
3313
3547
|
...(event.content === undefined ? {} : { result: event.content }),
|
|
3314
3548
|
...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
|
|
3549
|
+
...(executionMs === undefined ? {} : { executionMs }),
|
|
3315
3550
|
status,
|
|
3316
3551
|
}, historyUpdate);
|
|
3317
3552
|
return;
|
|
@@ -3324,7 +3559,7 @@ function applyEvent(parts, event) {
|
|
|
3324
3559
|
...group,
|
|
3325
3560
|
status,
|
|
3326
3561
|
...(event.content === undefined ? {} : { result: event.content }),
|
|
3327
|
-
...(
|
|
3562
|
+
...(executionMs === undefined ? {} : { executionMs }),
|
|
3328
3563
|
}, historyUpdate));
|
|
3329
3564
|
return;
|
|
3330
3565
|
}
|
|
@@ -3339,6 +3574,7 @@ function applyEvent(parts, event) {
|
|
|
3339
3574
|
// re-render an answered question after a reload, so keep the structured
|
|
3340
3575
|
// payload beside the prose rather than reparsing the sentence.
|
|
3341
3576
|
...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
|
|
3577
|
+
...(executionMs === undefined ? {} : { executionMs }),
|
|
3342
3578
|
status,
|
|
3343
3579
|
}, historyUpdate);
|
|
3344
3580
|
return;
|
|
@@ -3378,6 +3614,16 @@ function existingToolName(parts, id) {
|
|
|
3378
3614
|
const existing = parts.find((part) => part.type === "tool-call" && part.toolCallId === id);
|
|
3379
3615
|
return existing?.type === "tool-call" ? existing.toolName : undefined;
|
|
3380
3616
|
}
|
|
3617
|
+
/**
|
|
3618
|
+
* A duration is only worth persisting when it is a finite, non-negative number.
|
|
3619
|
+
* The stream wire validates `type` and nothing else, and providers derive this
|
|
3620
|
+
* from raw wall-clock subtraction, so a backward clock step or a provider that
|
|
3621
|
+
* reports `NaN` would otherwise be written verbatim and then rejected by the
|
|
3622
|
+
* read-back validator — permanently refusing to open the store.
|
|
3623
|
+
*/
|
|
3624
|
+
function canonicalExecutionMs(value) {
|
|
3625
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
3626
|
+
}
|
|
3381
3627
|
function canonicalEventHistoryUpdate(value) {
|
|
3382
3628
|
return value === undefined
|
|
3383
3629
|
? undefined
|
|
@@ -4002,6 +4248,12 @@ function isWebToolCall(value) {
|
|
|
4002
4248
|
return typeof call.toolCallId === "string"
|
|
4003
4249
|
&& typeof call.toolName === "string"
|
|
4004
4250
|
&& isWebToolCallStatus(call.status)
|
|
4251
|
+
// Nullish is accepted, not just absent: `JSON.stringify` writes NaN and
|
|
4252
|
+
// Infinity as `null`, so a database written before durations were
|
|
4253
|
+
// canonicalized can hold one. `validateStorage()` re-parses every message at
|
|
4254
|
+
// open, so being strict here would refuse the whole store over a
|
|
4255
|
+
// display-only value the renderers already drop.
|
|
4256
|
+
&& (call.executionMs == null || typeof call.executionMs === "number")
|
|
4005
4257
|
&& (call.history === undefined || isSessionToolHistoryMetadata(call.history));
|
|
4006
4258
|
}
|
|
4007
4259
|
const SESSION_TOOL_HISTORY_TERMINAL_STATES = new Set([
|
|
@@ -4140,7 +4392,7 @@ function isWebMessagePart(value) {
|
|
|
4140
4392
|
return typeof part.toolCallId === "string"
|
|
4141
4393
|
&& typeof part.name === "string"
|
|
4142
4394
|
&& (part.label === undefined || typeof part.label === "string")
|
|
4143
|
-
&& (part.executionMs
|
|
4395
|
+
&& (part.executionMs == null || typeof part.executionMs === "number")
|
|
4144
4396
|
&& (part.costUsd === undefined || typeof part.costUsd === "number")
|
|
4145
4397
|
&& (part.history === undefined || isSessionToolHistoryMetadata(part.history))
|
|
4146
4398
|
&& isWebToolCallStatus(part.status)
|