@zhin.js/client 2.1.9 → 2.1.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.
Files changed (70) hide show
  1. package/README.md +6 -0
  2. package/client/console-events.ts +37 -0
  3. package/client/index.ts +44 -2
  4. package/client/mediaSrc.test.ts +15 -0
  5. package/client/mediaSrc.ts +59 -12
  6. package/client/message-content/MarkdownContent.tsx +128 -0
  7. package/client/message-content/markdown.test.ts +48 -0
  8. package/client/message-content/markdown.ts +247 -0
  9. package/client/persistence/idb-store.test.ts +11 -0
  10. package/client/persistence/idb-store.ts +20 -4
  11. package/client/websocket/index.ts +6 -1
  12. package/client/websocket/manager.test.ts +145 -12
  13. package/client/websocket/manager.ts +189 -17
  14. package/client/websocket/types.ts +5 -0
  15. package/client/workroom/WorkroomRunsPage.tsx +248 -0
  16. package/client/{orchestration/registerOrchestrationConsole.test.ts → workroom/registerWorkroomConsole.test.ts} +8 -8
  17. package/client/workroom/registerWorkroomConsole.ts +17 -0
  18. package/dist/console-events.d.ts +7 -0
  19. package/dist/console-events.d.ts.map +1 -0
  20. package/dist/console-events.js +26 -0
  21. package/dist/console-events.js.map +1 -0
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/mediaSrc.d.ts.map +1 -1
  27. package/dist/mediaSrc.js +66 -12
  28. package/dist/mediaSrc.js.map +1 -1
  29. package/dist/message-content/MarkdownContent.d.ts +11 -0
  30. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  31. package/dist/message-content/MarkdownContent.js +68 -0
  32. package/dist/message-content/MarkdownContent.js.map +1 -0
  33. package/dist/message-content/markdown.d.ts +47 -0
  34. package/dist/message-content/markdown.d.ts.map +1 -0
  35. package/dist/message-content/markdown.js +219 -0
  36. package/dist/message-content/markdown.js.map +1 -0
  37. package/dist/persistence/idb-store.d.ts +3 -0
  38. package/dist/persistence/idb-store.d.ts.map +1 -1
  39. package/dist/persistence/idb-store.js +13 -3
  40. package/dist/persistence/idb-store.js.map +1 -1
  41. package/dist/websocket/index.d.ts +1 -1
  42. package/dist/websocket/index.d.ts.map +1 -1
  43. package/dist/websocket/index.js +1 -1
  44. package/dist/websocket/index.js.map +1 -1
  45. package/dist/websocket/manager.d.ts +19 -0
  46. package/dist/websocket/manager.d.ts.map +1 -1
  47. package/dist/websocket/manager.js +176 -18
  48. package/dist/websocket/manager.js.map +1 -1
  49. package/dist/websocket/types.d.ts +4 -0
  50. package/dist/websocket/types.d.ts.map +1 -1
  51. package/dist/websocket/types.js.map +1 -1
  52. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  53. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  54. package/dist/workroom/WorkroomRunsPage.js +64 -0
  55. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  56. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  57. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  58. package/dist/workroom/registerWorkroomConsole.js +16 -0
  59. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  60. package/package.json +3 -3
  61. package/client/orchestration/OrchestrationRunsPage.tsx +0 -217
  62. package/client/orchestration/registerOrchestrationConsole.ts +0 -17
  63. package/dist/orchestration/OrchestrationRunsPage.d.ts +0 -2
  64. package/dist/orchestration/OrchestrationRunsPage.d.ts.map +0 -1
  65. package/dist/orchestration/OrchestrationRunsPage.js +0 -65
  66. package/dist/orchestration/OrchestrationRunsPage.js.map +0 -1
  67. package/dist/orchestration/registerOrchestrationConsole.d.ts +0 -4
  68. package/dist/orchestration/registerOrchestrationConsole.d.ts.map +0 -1
  69. package/dist/orchestration/registerOrchestrationConsole.js +0 -16
  70. package/dist/orchestration/registerOrchestrationConsole.js.map +0 -1
@@ -0,0 +1,247 @@
1
+ export type MarkdownInline =
2
+ | { readonly type: 'text'; readonly value: string }
3
+ | { readonly type: 'strong' | 'emphasis' | 'strike' | 'code'; readonly value: string }
4
+ | { readonly type: 'link'; readonly value: string; readonly href: string };
5
+
6
+ export type MarkdownBlock =
7
+ | { readonly type: 'heading'; readonly level: number; readonly content: MarkdownInline[] }
8
+ | { readonly type: 'paragraph' | 'quote'; readonly content: MarkdownInline[] }
9
+ | { readonly type: 'list'; readonly ordered: boolean; readonly items: ReadonlyArray<{ readonly content: MarkdownInline[]; readonly checked?: boolean }> }
10
+ | { readonly type: 'code'; readonly language: string; readonly value: string; readonly closed: boolean }
11
+ | { readonly type: 'table'; readonly header: ReadonlyArray<MarkdownInline[]>; readonly rows: ReadonlyArray<ReadonlyArray<MarkdownInline[]>> }
12
+ | { readonly type: 'rule' };
13
+
14
+ export type CodeTokenKind = 'plain' | 'comment' | 'string' | 'number' | 'keyword' | 'literal';
15
+
16
+ export interface CodeToken {
17
+ readonly kind: CodeTokenKind;
18
+ readonly value: string;
19
+ }
20
+
21
+ // Excluding another opening bracket from link labels prevents the regexp
22
+ // engine from rescanning an attacker-controlled run of `[` characters.
23
+ const INLINE_PATTERN = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(\[[^\[\]\n]+\]\([^\s()]+(?:\s+"[^"\n]*")?\))|(https?:\/\/[^\s<>()]+)/g;
24
+ const TABLE_DIVIDER = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
25
+
26
+ export function isSafeMarkdownHref(href: string): boolean {
27
+ const value = href.trim();
28
+ if (!value) return false;
29
+ if (/^(https?:|mailto:)/i.test(value)) return true;
30
+ return /^(\/|#|\.\/|\.\.\/)/.test(value);
31
+ }
32
+
33
+ export function parseMarkdownInline(value: string): MarkdownInline[] {
34
+ const tokens: MarkdownInline[] = [];
35
+ let cursor = 0;
36
+ let match: RegExpExecArray | null;
37
+ INLINE_PATTERN.lastIndex = 0;
38
+
39
+ while ((match = INLINE_PATTERN.exec(value)) !== null) {
40
+ if (match.index > cursor) tokens.push({ type: 'text', value: value.slice(cursor, match.index) });
41
+ const raw = match[0];
42
+ if (match[1]) {
43
+ tokens.push({ type: 'code', value: raw.slice(1, -1) });
44
+ } else if (match[2]) {
45
+ tokens.push({ type: 'strong', value: raw.slice(2, -2) });
46
+ } else if (match[3]) {
47
+ tokens.push({ type: 'strike', value: raw.slice(2, -2) });
48
+ } else if (match[4]) {
49
+ tokens.push({ type: 'emphasis', value: raw.slice(1, -1) });
50
+ } else if (match[5]) {
51
+ const parsed = raw.match(/^\[([^\[\]]+)\]\(([^\s()]+)(?:\s+"[^"\n]*")?\)$/);
52
+ if (parsed && isSafeMarkdownHref(parsed[2])) {
53
+ tokens.push({ type: 'link', value: parsed[1], href: parsed[2] });
54
+ } else {
55
+ tokens.push({ type: 'text', value: raw });
56
+ }
57
+ } else if (match[6]) {
58
+ tokens.push({ type: 'link', value: raw, href: raw });
59
+ }
60
+ cursor = INLINE_PATTERN.lastIndex;
61
+ }
62
+
63
+ if (cursor < value.length) tokens.push({ type: 'text', value: value.slice(cursor) });
64
+ const merged: MarkdownInline[] = [];
65
+ for (const token of tokens) {
66
+ const previous = merged.at(-1);
67
+ if (token.type === 'text' && previous?.type === 'text') {
68
+ merged[merged.length - 1] = { type: 'text', value: previous.value + token.value };
69
+ } else {
70
+ merged.push(token);
71
+ }
72
+ }
73
+ return merged.length > 0 ? merged : [{ type: 'text', value }];
74
+ }
75
+
76
+ function splitTableRow(line: string): string[] {
77
+ return line
78
+ .trim()
79
+ .replace(/^\|/, '')
80
+ .replace(/\|$/, '')
81
+ .split(/(?<!\\)\|/)
82
+ .map((cell) => cell.trim().replace(/\\\|/g, '|'));
83
+ }
84
+
85
+ function startsBlock(lines: readonly string[], index: number): boolean {
86
+ const line = lines[index] ?? '';
87
+ return /^(?:```|~~~|#{1,6}\s+|>\s?|\s*(?:[-+*]|\d+[.)])\s+|\s*(?:-{3,}|\*{3,}|_{3,})\s*$)/.test(line)
88
+ || (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? ''));
89
+ }
90
+
91
+ export function parseMarkdown(value: string): MarkdownBlock[] {
92
+ const lines = value.replace(/\r\n?/g, '\n').split('\n');
93
+ const blocks: MarkdownBlock[] = [];
94
+ let index = 0;
95
+
96
+ while (index < lines.length) {
97
+ const line = lines[index];
98
+ if (!line.trim()) {
99
+ index += 1;
100
+ continue;
101
+ }
102
+
103
+ const fence = line.match(/^\s*(```|~~~)\s*([^\s`]*)?.*$/);
104
+ if (fence) {
105
+ const marker = fence[1];
106
+ const language = (fence[2] ?? '').toLowerCase();
107
+ const code: string[] = [];
108
+ let closed = false;
109
+ index += 1;
110
+ while (index < lines.length) {
111
+ if (lines[index].trim() === marker) {
112
+ closed = true;
113
+ index += 1;
114
+ break;
115
+ }
116
+ code.push(lines[index]);
117
+ index += 1;
118
+ }
119
+ blocks.push({ type: 'code', language, value: code.join('\n'), closed });
120
+ continue;
121
+ }
122
+
123
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
124
+ if (heading) {
125
+ blocks.push({ type: 'heading', level: heading[1].length, content: parseMarkdownInline(heading[2]) });
126
+ index += 1;
127
+ continue;
128
+ }
129
+
130
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
131
+ blocks.push({ type: 'rule' });
132
+ index += 1;
133
+ continue;
134
+ }
135
+
136
+ if (/^>\s?/.test(line)) {
137
+ const quoted: string[] = [];
138
+ while (index < lines.length && /^>\s?/.test(lines[index])) {
139
+ quoted.push(lines[index].replace(/^>\s?/, ''));
140
+ index += 1;
141
+ }
142
+ blocks.push({ type: 'quote', content: parseMarkdownInline(quoted.join('\n')) });
143
+ continue;
144
+ }
145
+
146
+ const listMatch = line.match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
147
+ if (listMatch) {
148
+ const ordered = /^\d/.test(listMatch[1]);
149
+ const items: Array<{ content: MarkdownInline[]; checked?: boolean }> = [];
150
+ while (index < lines.length) {
151
+ const item = lines[index].match(/^\s*([-+*]|\d+[.)])\s+(.+)$/);
152
+ if (!item || /^\d/.test(item[1]) !== ordered) break;
153
+ const task = item[2].match(/^\[([ xX])\]\s+(.+)$/);
154
+ items.push({
155
+ content: parseMarkdownInline(task ? task[2] : item[2]),
156
+ ...(task ? { checked: task[1].toLowerCase() === 'x' } : {}),
157
+ });
158
+ index += 1;
159
+ }
160
+ blocks.push({ type: 'list', ordered, items });
161
+ continue;
162
+ }
163
+
164
+ if (line.includes('|') && TABLE_DIVIDER.test(lines[index + 1] ?? '')) {
165
+ const header = splitTableRow(line).map(parseMarkdownInline);
166
+ const rows: MarkdownInline[][][] = [];
167
+ index += 2;
168
+ while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
169
+ rows.push(splitTableRow(lines[index]).map(parseMarkdownInline));
170
+ index += 1;
171
+ }
172
+ blocks.push({ type: 'table', header, rows });
173
+ continue;
174
+ }
175
+
176
+ const paragraph = [line];
177
+ index += 1;
178
+ while (index < lines.length && lines[index].trim() && !startsBlock(lines, index)) {
179
+ paragraph.push(lines[index]);
180
+ index += 1;
181
+ }
182
+ blocks.push({ type: 'paragraph', content: parseMarkdownInline(paragraph.join('\n')) });
183
+ }
184
+
185
+ return blocks;
186
+ }
187
+
188
+ const KEYWORDS: Record<string, ReadonlySet<string>> = {
189
+ javascript: new Set(['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'static', 'switch', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'yield']),
190
+ typescript: new Set(['abstract', 'any', 'as', 'asserts', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'keyof', 'let', 'namespace', 'never', 'new', 'number', 'object', 'of', 'readonly', 'return', 'satisfies', 'static', 'string', 'switch', 'symbol', 'throw', 'try', 'type', 'typeof', 'undefined', 'unknown', 'var', 'void', 'while', 'yield']),
191
+ python: new Set(['and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']),
192
+ shell: new Set(['case', 'do', 'done', 'elif', 'else', 'esac', 'export', 'fi', 'for', 'function', 'if', 'in', 'local', 'then', 'while']),
193
+ css: new Set(['@import', '@media', '@supports', 'and', 'important', 'not', 'only']),
194
+ };
195
+
196
+ const LANGUAGE_ALIASES: Record<string, string> = {
197
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
198
+ ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
199
+ py: 'python', bash: 'shell', sh: 'shell', zsh: 'shell', jsonc: 'json',
200
+ };
201
+
202
+ export function highlightCodeLine(line: string, language = ''): CodeToken[] {
203
+ const normalized = LANGUAGE_ALIASES[language] ?? language;
204
+ const keywords = KEYWORDS[normalized] ?? KEYWORDS.javascript;
205
+ const tokens: CodeToken[] = [];
206
+ let index = 0;
207
+
208
+ const push = (kind: CodeTokenKind, value: string) => {
209
+ if (value) tokens.push({ kind, value });
210
+ };
211
+
212
+ while (index < line.length) {
213
+ const rest = line.slice(index);
214
+ const comment = normalized === 'python' || normalized === 'shell'
215
+ ? rest.match(/^#.*/)
216
+ : rest.match(/^\/\/.*|^\/\*.*?\*\//);
217
+ if (comment) {
218
+ push('comment', comment[0]);
219
+ index += comment[0].length;
220
+ continue;
221
+ }
222
+ const string = rest.match(/^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/);
223
+ if (string) {
224
+ push('string', string[0]);
225
+ index += string[0].length;
226
+ continue;
227
+ }
228
+ const number = rest.match(/^(?:0x[\da-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i);
229
+ if (number) {
230
+ push('number', number[0]);
231
+ index += number[0].length;
232
+ continue;
233
+ }
234
+ const word = rest.match(/^[A-Za-z_$@][\w$-]*/);
235
+ if (word) {
236
+ const literal = /^(?:true|false|null|undefined|None|True|False)$/.test(word[0]);
237
+ push(literal ? 'literal' : keywords.has(word[0]) ? 'keyword' : 'plain', word[0]);
238
+ index += word[0].length;
239
+ continue;
240
+ }
241
+ const plain = rest.match(/^[^A-Za-z_$@'"`\d/#]+/) ?? rest.match(/^./);
242
+ push('plain', plain?.[0] ?? '');
243
+ index += plain?.[0].length ?? 1;
244
+ }
245
+
246
+ return tokens;
247
+ }
@@ -108,6 +108,17 @@ describe("applyConsoleEvent inbox record ids", () => {
108
108
  expect(new Set(rows.map((r) => r.id)).size).toBe(2);
109
109
  });
110
110
 
111
+ it('deduplicates the same resumable event across history and live delivery', async () => {
112
+ const event = { ...PUSH, runtimeId: 'runtime-a', eventId: 7, timestamp: 1700000000000 };
113
+ await applyConsoleEvent(event);
114
+ await applyConsoleEvent(event);
115
+
116
+ const rows = await idbListInbox('icqq', 'bot-1', 'message');
117
+ expect(rows).toHaveLength(1);
118
+ expect(rows[0]?.id).toBe('runtime-a:7');
119
+ expect(rows[0]?.updatedAt).toBe(1700000000000);
120
+ });
121
+
111
122
  it("reads legacy records that only carry endpointKey (camelCase)", async () => {
112
123
  // 旧版(DB v1 时代)记录只有 endpointKey 字段,升级后仍需能被列出
113
124
  const { idbPutInbox } = await import("./idb-store.js");
@@ -66,13 +66,29 @@ export async function idbListInbox(
66
66
  );
67
67
  }
68
68
 
69
- export async function applyConsoleEvent(event: { type: string; data?: unknown }): Promise<void> {
69
+ export async function applyConsoleEvent(event: {
70
+ type: string;
71
+ data?: unknown;
72
+ runtimeId?: string;
73
+ eventId?: number;
74
+ timestamp?: number;
75
+ }): Promise<void> {
70
76
  const parsed = parseConsoleInboxEvent(event);
71
77
  if (!parsed) return;
72
- const updatedAt = Date.now();
78
+ const updatedAt = typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)
79
+ ? event.timestamp
80
+ : Date.now();
81
+ const resumableId = typeof event.runtimeId === 'string'
82
+ && event.runtimeId
83
+ && Number.isSafeInteger(event.eventId)
84
+ && (event.eventId ?? 0) > 0
85
+ ? `${event.runtimeId}:${event.eventId}`
86
+ : null;
73
87
  await idbPutInbox({
74
- // 追加自增+随机后缀:同毫秒同会话的两条事件不会因主键相同而互相覆盖
75
- id: `${parsed.adapter}:${parsed.endpointKey}:${parsed.type}:${updatedAt}:${inboxEventSeq++}:${Math.random().toString(36).slice(2, 8)}`,
88
+ // Resumable Host events are idempotent across history/live redelivery.
89
+ // Legacy/unsequenced sources retain collision-safe append ids.
90
+ id: resumableId
91
+ ?? `${parsed.adapter}:${parsed.endpointKey}:${parsed.type}:${updatedAt}:${inboxEventSeq++}:${Math.random().toString(36).slice(2, 8)}`,
76
92
  adapter: parsed.adapter,
77
93
  endpoint_id: parsed.endpointKey,
78
94
  kind: parsed.kind,
@@ -1,5 +1,10 @@
1
1
  export * from "./types";
2
- export { WebSocketManager } from "./manager";
2
+ export {
3
+ WebSocketManager,
4
+ type ConsoleEventListener,
5
+ type ConsoleEventRecoveryGap,
6
+ type ConsoleEventRecoveryGapListener,
7
+ } from "./manager";
3
8
  export { getWebSocketManager, destroyWebSocketManager, resetWebSocketManager } from "./instance";
4
9
  export { useWebSocket, useConfig, useConfigYaml, useFiles, useEnvFiles, useDatabase } from "./hooks";
5
10
  import { getWebSocketManager } from "./instance";
@@ -54,6 +54,32 @@ function sseResponse(status = 200, body?: ReadableStream<Uint8Array> | null) {
54
54
  } as Response;
55
55
  }
56
56
 
57
+ function eventHistoryResponse(items: unknown[] = [], runtimeId = 'runtime-test', latestEventId?: number) {
58
+ const inferredLatest = (items.at(-1) as { eventId?: number } | undefined)?.eventId ?? 0;
59
+ return {
60
+ ok: true,
61
+ status: 200,
62
+ json: async () => ({
63
+ success: true,
64
+ data: {
65
+ runtimeId,
66
+ items,
67
+ oldestAvailableEventId: items.length ? 1 : null,
68
+ latestEventId: latestEventId ?? inferredLatest,
69
+ nextAfter: inferredLatest,
70
+ hasMore: false,
71
+ gap: false,
72
+ },
73
+ }),
74
+ } as Response;
75
+ }
76
+
77
+ function mockConsoleTransport(sse: () => Response | Promise<Response>) {
78
+ return vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
79
+ return String(input).includes('/api/events/history') ? eventHistoryResponse() : await sse();
80
+ });
81
+ }
82
+
57
83
  describe("WebSocketManager REST/SSE transport", () => {
58
84
  beforeEach(() => {
59
85
  vi.useFakeTimers();
@@ -68,7 +94,7 @@ describe("WebSocketManager REST/SSE transport", () => {
68
94
 
69
95
  it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
70
96
  installBrowserGlobals();
71
- const fetchMock = vi.fn(async () => sseResponse(200));
97
+ const fetchMock = mockConsoleTransport(() => sseResponse(200));
72
98
  vi.stubGlobal("fetch", fetchMock);
73
99
 
74
100
  const manager = new WebSocketManager({ reconnectInterval: 100, maxReconnectAttempts: 3 });
@@ -81,7 +107,8 @@ describe("WebSocketManager REST/SSE transport", () => {
81
107
  await Promise.resolve();
82
108
 
83
109
  expect(fetchMock).toHaveBeenCalled();
84
- const init = fetchMock.mock.calls[0]![1] as RequestInit;
110
+ const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
111
+ const init = sseCall[1] as RequestInit;
85
112
  const headers = init.headers as Record<string, string>;
86
113
  expect(headers.Accept).toBe("text/event-stream");
87
114
  expect(headers.Authorization).toBeUndefined();
@@ -92,7 +119,7 @@ describe("WebSocketManager REST/SSE transport", () => {
92
119
 
93
120
  it("sends Authorization when a token is stored", async () => {
94
121
  installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
95
- const fetchMock = vi.fn(async () => sseResponse(200));
122
+ const fetchMock = mockConsoleTransport(() => sseResponse(200));
96
123
  vi.stubGlobal("fetch", fetchMock);
97
124
 
98
125
  const manager = new WebSocketManager();
@@ -101,17 +128,18 @@ describe("WebSocketManager REST/SSE transport", () => {
101
128
  await Promise.resolve();
102
129
  await Promise.resolve();
103
130
 
104
- const init = fetchMock.mock.calls[0]![1] as RequestInit;
131
+ const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
132
+ const init = sseCall[1] as RequestInit;
105
133
  expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
106
134
  manager.disconnect();
107
135
  });
108
136
 
109
137
  it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
110
138
  installBrowserGlobals();
111
- let calls = 0;
112
- const fetchMock = vi.fn(async () => {
113
- calls += 1;
114
- if (calls === 1) return sseResponse(503, null);
139
+ let sseCalls = 0;
140
+ const fetchMock = mockConsoleTransport(() => {
141
+ sseCalls += 1;
142
+ if (sseCalls === 1) return sseResponse(503, null);
115
143
  return sseResponse(200);
116
144
  });
117
145
  vi.stubGlobal("fetch", fetchMock);
@@ -127,14 +155,14 @@ describe("WebSocketManager REST/SSE transport", () => {
127
155
  await Promise.resolve();
128
156
  await Promise.resolve();
129
157
  expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
130
- expect(calls).toBe(1);
158
+ expect(sseCalls).toBe(1);
131
159
 
132
160
  // scheduleReconnect delay = 100 * attempt(1) = 100ms
133
161
  await vi.advanceTimersByTimeAsync(100);
134
162
  await Promise.resolve();
135
163
  await Promise.resolve();
136
164
 
137
- expect(calls).toBeGreaterThanOrEqual(2);
165
+ expect(sseCalls).toBeGreaterThanOrEqual(2);
138
166
  expect(manager.getState()).toBe(ConnectionState.CONNECTED);
139
167
  manager.disconnect();
140
168
  });
@@ -194,7 +222,7 @@ describe("WebSocketManager REST/SSE transport", () => {
194
222
 
195
223
  it("stops reconnecting after maxReconnectAttempts", async () => {
196
224
  installBrowserGlobals();
197
- const fetchMock = vi.fn(async () => sseResponse(500, null));
225
+ const fetchMock = mockConsoleTransport(() => sseResponse(500, null));
198
226
  vi.stubGlobal("fetch", fetchMock);
199
227
 
200
228
  const manager = new WebSocketManager({
@@ -210,7 +238,112 @@ describe("WebSocketManager REST/SSE transport", () => {
210
238
  }
211
239
 
212
240
  expect(manager.getState()).toBe(ConnectionState.ERROR);
213
- expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
241
+ const sseCalls = fetchMock.mock.calls.filter(([input]) => !String(input).includes('/history'));
242
+ expect(sseCalls.length).toBeLessThanOrEqual(3);
214
243
  manager.disconnect();
215
244
  });
245
+
246
+ it('recovers typed history before live SSE and preserves event metadata', async () => {
247
+ installBrowserGlobals();
248
+ const encoder = new TextEncoder();
249
+ const stream = new ReadableStream<Uint8Array>({
250
+ start(controller) {
251
+ controller.enqueue(encoder.encode([
252
+ 'id: 2',
253
+ 'event: message.receive',
254
+ 'runtime: runtime-a',
255
+ 'timestamp: 200',
256
+ 'data: {"adapter":"sandbox","endpointKey":"bot","content":"live"}',
257
+ '',
258
+ '',
259
+ ].join('\n')));
260
+ },
261
+ });
262
+ const historyEvent = {
263
+ runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
264
+ data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
265
+ };
266
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => (
267
+ String(input).includes('/history')
268
+ ? eventHistoryResponse([historyEvent], 'runtime-a')
269
+ : sseResponse(200, stream)
270
+ ));
271
+ vi.stubGlobal('fetch', fetchMock);
272
+ const received: Array<{ eventId: number; delivery?: string }> = [];
273
+ const manager = new WebSocketManager();
274
+ manager.onConsoleEvent('message.receive', (event) => received.push({ eventId: event.eventId, delivery: event.delivery }));
275
+ manager.connect();
276
+ await vi.advanceTimersByTimeAsync(0);
277
+ await Promise.resolve();
278
+ await Promise.resolve();
279
+
280
+ expect(received).toEqual([
281
+ { eventId: 1, delivery: 'history' },
282
+ { eventId: 2, delivery: 'live' },
283
+ ]);
284
+ expect(mockApplyConsoleEvent).toHaveBeenCalledTimes(2);
285
+ manager.disconnect();
286
+ });
287
+
288
+ it('keeps an in-memory cursor when localStorage is unavailable', async () => {
289
+ const storage = mockStorage();
290
+ storage.setItem = () => { throw new Error('storage disabled'); };
291
+ installBrowserGlobals(storage);
292
+ const historyEvent = {
293
+ runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
294
+ data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
295
+ };
296
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
297
+ const after = new URL(String(input)).searchParams.get('after');
298
+ return after === '1'
299
+ ? eventHistoryResponse([], 'runtime-a', 1)
300
+ : eventHistoryResponse([historyEvent], 'runtime-a', 1);
301
+ });
302
+ vi.stubGlobal('fetch', fetchMock);
303
+ const received: number[] = [];
304
+ const manager = new WebSocketManager();
305
+ manager.onConsoleEvent('message.receive', (event) => received.push(event.eventId));
306
+ const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
307
+ await recover.recoverEventHistory(new AbortController().signal);
308
+ await recover.recoverEventHistory(new AbortController().signal);
309
+
310
+ expect(received).toEqual([1]);
311
+ expect(fetchMock.mock.calls[1]?.[0]).toContain('after=1');
312
+ });
313
+
314
+ it('exposes a typed recovery gap instead of silently treating it as complete', async () => {
315
+ installBrowserGlobals();
316
+ const response = eventHistoryResponse([], 'runtime-new', 9);
317
+ const originalJson = response.json;
318
+ response.json = async () => {
319
+ const body = await originalJson() as { data: Record<string, unknown> };
320
+ return { ...body, data: { ...body.data, gap: true, oldestAvailableEventId: 8 } };
321
+ };
322
+ vi.stubGlobal('fetch', vi.fn(async () => response));
323
+ const manager = new WebSocketManager();
324
+ const gaps: unknown[] = [];
325
+ manager.onConsoleEventRecoveryGap((gap) => gaps.push(gap));
326
+ const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
327
+ await recover.recoverEventHistory(new AbortController().signal);
328
+
329
+ expect(gaps).toEqual([expect.objectContaining({
330
+ page: expect.objectContaining({ runtimeId: 'runtime-new', gap: true }),
331
+ })]);
332
+ });
333
+
334
+ it('does not persist a cursor past an inbox event that failed durable storage', async () => {
335
+ const stored: Record<string, string> = {};
336
+ installBrowserGlobals(mockStorage(stored));
337
+ mockApplyConsoleEvent.mockRejectedValueOnce(new Error('IndexedDB unavailable'));
338
+ const historyEvent = {
339
+ runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
340
+ data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
341
+ };
342
+ vi.stubGlobal('fetch', vi.fn(async () => eventHistoryResponse([historyEvent], 'runtime-a', 1)));
343
+ const manager = new WebSocketManager();
344
+ const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
345
+ await recover.recoverEventHistory(new AbortController().signal);
346
+
347
+ expect(Object.keys(stored).filter((key) => key.includes('event-cursor'))).toEqual([]);
348
+ });
216
349
  });