@zhin.js/client 1.1.3 → 1.1.5

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 (104) hide show
  1. package/README.md +89 -433
  2. package/client/agent-stream.ts +121 -0
  3. package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
  4. package/client/bootstrap/loadConsoleEntries.ts +40 -8
  5. package/client/console-events.ts +37 -0
  6. package/client/console-utils/remoteApi.test.ts +91 -0
  7. package/client/console-utils/remoteApi.ts +47 -2
  8. package/client/index.ts +75 -19
  9. package/client/mediaSrc.test.ts +49 -0
  10. package/client/mediaSrc.ts +84 -11
  11. package/client/message-content/MarkdownContent.tsx +128 -0
  12. package/client/message-content/markdown.test.ts +48 -0
  13. package/client/message-content/markdown.ts +247 -0
  14. package/client/persistence/idb-store.test.ts +139 -0
  15. package/client/persistence/idb-store.ts +43 -21
  16. package/client/runtime/index.ts +1 -1
  17. package/client/segments.test.ts +16 -0
  18. package/client/segments.ts +39 -0
  19. package/client/types.ts +5 -5
  20. package/client/websocket/hooks.test.ts +21 -0
  21. package/client/websocket/hooks.ts +56 -27
  22. package/client/websocket/index.ts +6 -1
  23. package/client/websocket/manager.test.ts +349 -0
  24. package/client/websocket/manager.ts +245 -54
  25. package/client/websocket/remote-settings.ts +25 -3
  26. package/client/websocket/types.ts +5 -0
  27. package/client/workroom/WorkroomRunsPage.tsx +355 -0
  28. package/client/workroom/registerWorkroomConsole.test.ts +26 -0
  29. package/client/workroom/registerWorkroomConsole.ts +17 -0
  30. package/dist/agent-stream.d.ts +17 -0
  31. package/dist/agent-stream.d.ts.map +1 -0
  32. package/dist/agent-stream.js +73 -0
  33. package/dist/agent-stream.js.map +1 -0
  34. package/dist/bootstrap/loadConsoleEntries.d.ts +10 -1
  35. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  36. package/dist/bootstrap/loadConsoleEntries.js +37 -2
  37. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  38. package/dist/console-events.d.ts +7 -0
  39. package/dist/console-events.d.ts.map +1 -0
  40. package/dist/console-events.js +26 -0
  41. package/dist/console-events.js.map +1 -0
  42. package/dist/console-utils/remoteApi.d.ts +2 -0
  43. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  44. package/dist/console-utils/remoteApi.js +46 -2
  45. package/dist/console-utils/remoteApi.js.map +1 -1
  46. package/dist/index.d.ts +9 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -3
  49. package/dist/index.js.map +1 -1
  50. package/dist/mediaSrc.d.ts +1 -1
  51. package/dist/mediaSrc.d.ts.map +1 -1
  52. package/dist/mediaSrc.js +91 -11
  53. package/dist/mediaSrc.js.map +1 -1
  54. package/dist/message-content/MarkdownContent.d.ts +11 -0
  55. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  56. package/dist/message-content/MarkdownContent.js +68 -0
  57. package/dist/message-content/MarkdownContent.js.map +1 -0
  58. package/dist/message-content/markdown.d.ts +47 -0
  59. package/dist/message-content/markdown.d.ts.map +1 -0
  60. package/dist/message-content/markdown.js +219 -0
  61. package/dist/message-content/markdown.js.map +1 -0
  62. package/dist/persistence/idb-store.d.ts +5 -2
  63. package/dist/persistence/idb-store.d.ts.map +1 -1
  64. package/dist/persistence/idb-store.js +31 -20
  65. package/dist/persistence/idb-store.js.map +1 -1
  66. package/dist/runtime/index.d.ts +1 -1
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/segments.d.ts +9 -0
  69. package/dist/segments.d.ts.map +1 -0
  70. package/dist/segments.js +35 -0
  71. package/dist/segments.js.map +1 -0
  72. package/dist/types.d.ts +4 -3
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/websocket/hooks.d.ts +9 -0
  75. package/dist/websocket/hooks.d.ts.map +1 -1
  76. package/dist/websocket/hooks.js +52 -31
  77. package/dist/websocket/hooks.js.map +1 -1
  78. package/dist/websocket/index.d.ts +1 -1
  79. package/dist/websocket/index.d.ts.map +1 -1
  80. package/dist/websocket/index.js +1 -1
  81. package/dist/websocket/index.js.map +1 -1
  82. package/dist/websocket/manager.d.ts +20 -2
  83. package/dist/websocket/manager.d.ts.map +1 -1
  84. package/dist/websocket/manager.js +222 -44
  85. package/dist/websocket/manager.js.map +1 -1
  86. package/dist/websocket/remote-settings.d.ts.map +1 -1
  87. package/dist/websocket/remote-settings.js +27 -3
  88. package/dist/websocket/remote-settings.js.map +1 -1
  89. package/dist/websocket/types.d.ts +4 -0
  90. package/dist/websocket/types.d.ts.map +1 -1
  91. package/dist/websocket/types.js.map +1 -1
  92. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  93. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  94. package/dist/workroom/WorkroomRunsPage.js +111 -0
  95. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  96. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  97. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  98. package/dist/workroom/registerWorkroomConsole.js +16 -0
  99. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  100. package/package.json +18 -8
  101. package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
  102. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
  103. package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
  104. package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
@@ -0,0 +1,39 @@
1
+ import type { MessageSegment } from './types.js';
2
+
3
+ /** IM 可见段白名单(与 core segment-contract/delivery 对齐) */
4
+ const IM_VISIBLE_TYPES = new Set([
5
+ 'text', 'mention', 'image', 'video', 'audio', 'voice', 'record', 'file',
6
+ 'face', 'reply', 'forward', 'link', 'dice', 'rps', 'markdown', 'html',
7
+ 'qrcode', 'tts', 'keyboard', 'action',
8
+ ]);
9
+
10
+ const AI_ONLY_TYPES = new Set(['thinking', 'tool_call']);
11
+
12
+ function isSegmentLike(value: unknown): value is MessageSegment {
13
+ return (
14
+ typeof value === 'object'
15
+ && value !== null
16
+ && typeof (value as MessageSegment).type === 'string'
17
+ && 'data' in value
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Inbox / 用户 IM 视图:仅展示 IM-visible 段(剔除 thinking、tool_call)。
23
+ * Agent 面板应使用全量 segments,勿调用此函数。
24
+ */
25
+ export function segmentsForImDelivery(segments: readonly unknown[]): MessageSegment[] {
26
+ const out: MessageSegment[] = [];
27
+ for (const item of segments) {
28
+ if (!isSegmentLike(item)) continue;
29
+ if (AI_ONLY_TYPES.has(item.type)) continue;
30
+ if (!IM_VISIBLE_TYPES.has(item.type)) continue;
31
+ out.push(item);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** Agent 面板全量 segments(identity,便于双视图 API 对称) */
37
+ export function segmentsForAgentPanel(segments: readonly MessageSegment[]): MessageSegment[] {
38
+ return [...segments];
39
+ }
package/client/types.ts CHANGED
@@ -1,8 +1,9 @@
1
- // 消息段类型定义 - 客户端版本
1
+ // 消息段类型定义 - 客户端版本(对齐 core Segment SSOT)
2
2
  export interface MessageSegment {
3
- /** record:如 ICQQ 语音等,展示时按音频处理 */
4
- type: 'text' | 'image' | 'at' | 'face' | 'video' | 'audio' | 'record' | 'file' | 'reply'
5
- data: Record<string, any>
3
+ /** record:如 ICQQ 语音等,展示时按音频处理;thinking/tool_call 仅 agent 面板展示 */
4
+ type: 'text' | 'mention' | 'at' | 'image' | 'face' | 'video' | 'audio' | 'record' | 'file' | 'reply' | 'forward' | 'keyboard' | 'action' | 'thinking' | 'tool_call' | string
5
+ data: Record<string, unknown>
6
+ platform?: Record<string, unknown>
6
7
  }
7
8
 
8
9
  // 消息元素别名(兼容不同命名习惯)
@@ -10,4 +11,3 @@ export type MessageElement = MessageSegment
10
11
 
11
12
  // 发送内容类型
12
13
  export type SendContent = MessageSegment | MessageSegment[] | string
13
-
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveKeyedValue } from "./hooks.js";
4
+
5
+ describe("resolveKeyedValue (useConfig stale-state guard)", () => {
6
+ it("returns the value when the entry key matches", () => {
7
+ const entry = { key: "plugin-a", value: { foo: 1 } };
8
+ expect(resolveKeyedValue(entry, "plugin-a")).toEqual({ foo: 1 });
9
+ });
10
+
11
+ it("treats a previous plugin's leftover state as empty after pluginName switches", () => {
12
+ // 回归:旧实现中 config state 跨 pluginName 残留,ready(config != null) 永远为真,
13
+ // 切换插件后不再自动加载新配置。
14
+ const stale = { key: "plugin-a", value: { foo: 1 } };
15
+ expect(resolveKeyedValue(stale, "plugin-b")).toBeNull();
16
+ });
17
+
18
+ it("returns null when nothing has been loaded", () => {
19
+ expect(resolveKeyedValue(null, "plugin-a")).toBeNull();
20
+ });
21
+ });
@@ -1,7 +1,25 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { getWebSocketManager } from "./instance";
3
3
  import type { UseWebSocketOptions } from "./types";
4
4
 
5
+ /**
6
+ * 每次连接会话只对同一 key 自动加载一次:失败后不再零退避死循环
7
+ * (旧行为:加载失败 → 状态保持空 → effect 反复立即重试,打爆 /api/console/request)。
8
+ * 断连后重置;key 变化(如 pluginName 切换)视为新目标重新尝试;手动重试走各 hook 返回的 load 函数。
9
+ */
10
+ function useAutoLoadOnce(connected: boolean, key: unknown, ready: boolean, fire: () => void) {
11
+ const attemptedRef = useRef<unknown>(undefined);
12
+ useEffect(() => {
13
+ if (!connected) {
14
+ attemptedRef.current = undefined;
15
+ return;
16
+ }
17
+ if (ready || attemptedRef.current === key) return;
18
+ attemptedRef.current = key;
19
+ fire();
20
+ });
21
+ }
22
+
5
23
  export function useWebSocket(options: UseWebSocketOptions = {}) {
6
24
  const { autoConnect = true } = options;
7
25
  const wsManager = getWebSocketManager();
@@ -26,14 +44,28 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
26
44
  return { connected, connect, disconnect, send, sendRequest, manager: wsManager };
27
45
  }
28
46
 
47
+ /**
48
+ * 按 key 绑定异步加载结果:key(如 pluginName)切换后,旧 key 的残留值视为 null,
49
+ * 避免 useConfig 的 ready 判断(config != null)被上一插件的 state 挡住而不再加载新配置。
50
+ * @internal
51
+ */
52
+ export function resolveKeyedValue<T>(
53
+ entry: { readonly key: string; readonly value: T } | null,
54
+ key: string,
55
+ ): T | null {
56
+ return entry && entry.key === key ? entry.value : null;
57
+ }
58
+
29
59
  export function useConfig(pluginName: string, options?: { autoLoad?: boolean; autoLoadSchema?: boolean }) {
30
60
  const { autoLoad = true, autoLoadSchema = true } = options ?? {};
31
61
  const wsManager = getWebSocketManager();
32
62
  const [connected, setConnected] = useState(wsManager.isConnected());
33
- const [config, setConfigState] = useState<unknown>(null);
34
- const [schema, setSchemaState] = useState<unknown>(null);
63
+ const [configEntry, setConfigEntry] = useState<{ key: string; value: unknown } | null>(null);
64
+ const [schemaEntry, setSchemaEntry] = useState<{ key: string; value: unknown } | null>(null);
35
65
  const [loading, setLoading] = useState(false);
36
66
  const [error, setError] = useState<string | null>(null);
67
+ const config = resolveKeyedValue(configEntry, pluginName);
68
+ const schema = resolveKeyedValue(schemaEntry, pluginName);
37
69
 
38
70
  useEffect(() => wsManager.onConnectionChange(setConnected), [wsManager]);
39
71
 
@@ -42,7 +74,7 @@ export function useConfig(pluginName: string, options?: { autoLoad?: boolean; au
42
74
  setError(null);
43
75
  try {
44
76
  const result = await wsManager.getConfig(pluginName);
45
- setConfigState(result);
77
+ setConfigEntry({ key: pluginName, value: result });
46
78
  return result;
47
79
  } catch (e) {
48
80
  setError(e instanceof Error ? e.message : "Unknown error");
@@ -71,20 +103,19 @@ export function useConfig(pluginName: string, options?: { autoLoad?: boolean; au
71
103
  const getSchema = useCallback(async () => {
72
104
  try {
73
105
  const result = await wsManager.getSchema(pluginName);
74
- if (result) setSchemaState(result);
106
+ if (result) setSchemaEntry({ key: pluginName, value: result });
75
107
  return result;
76
108
  } catch (e) {
77
109
  throw e;
78
110
  }
79
111
  }, [pluginName, wsManager]);
80
112
 
81
- useEffect(() => {
82
- if (connected && autoLoad && !config && !loading) getConfig().catch(() => {});
83
- }, [connected, autoLoad, config, loading, getConfig]);
84
-
85
- useEffect(() => {
86
- if (connected && autoLoadSchema && !schema) getSchema().catch(() => {});
87
- }, [connected, autoLoadSchema, schema, getSchema]);
113
+ useAutoLoadOnce(connected, `config:${pluginName}`, !autoLoad || config != null || loading, () => {
114
+ getConfig().catch(() => {});
115
+ });
116
+ useAutoLoadOnce(connected, `schema:${pluginName}`, !autoLoadSchema || schema != null, () => {
117
+ getSchema().catch(() => {});
118
+ });
88
119
 
89
120
  return useMemo(
90
121
  () => ({ config, schema, loading, error, connected, getConfig, setConfig, getSchema }),
@@ -136,9 +167,9 @@ export function useConfigYaml() {
136
167
  [wsManager],
137
168
  );
138
169
 
139
- useEffect(() => {
140
- if (connected && !yaml && !loading) load().catch(() => {});
141
- }, [connected, yaml, loading, load]);
170
+ useAutoLoadOnce(connected, "config-yaml", !!yaml || loading, () => {
171
+ load().catch(() => {});
172
+ });
142
173
 
143
174
  return useMemo(() => ({ yaml, pluginKeys, loading, error, load, save }), [yaml, pluginKeys, loading, error, load, save]);
144
175
  }
@@ -173,9 +204,9 @@ export function useFiles() {
173
204
  [wsManager],
174
205
  );
175
206
 
176
- useEffect(() => {
177
- if (connected && tree.length === 0 && !loading) loadTree().catch(() => {});
178
- }, [connected, tree.length, loading, loadTree]);
207
+ useAutoLoadOnce(connected, "file-tree", tree.length > 0 || loading, () => {
208
+ loadTree().catch(() => {});
209
+ });
179
210
 
180
211
  return useMemo(() => ({ tree, loading, error, loadTree, readFile, saveFile }), [tree, loading, error, loadTree, readFile, saveFile]);
181
212
  }
@@ -217,9 +248,9 @@ export function useEnvFiles() {
217
248
  [wsManager],
218
249
  );
219
250
 
220
- useEffect(() => {
221
- if (connected && files.length === 0 && !loading) listFiles().catch(() => {});
222
- }, [connected, files.length, loading, listFiles]);
251
+ useAutoLoadOnce(connected, "env-files", files.length > 0 || loading, () => {
252
+ listFiles().catch(() => {});
253
+ });
223
254
 
224
255
  return useMemo(
225
256
  () => ({ files, loading, error, listFiles, getFile, saveFile }),
@@ -275,12 +306,10 @@ export function useDatabase() {
275
306
  const kvDelete = useCallback(async (table: string, key: string) => wsManager.kvDelete(table, key), [wsManager]);
276
307
  const kvEntries = useCallback(async (table: string) => wsManager.kvGetEntries(table), [wsManager]);
277
308
 
278
- useEffect(() => {
279
- if (connected && !info && !loading) {
280
- loadInfo().catch(() => {});
281
- loadTables().catch(() => {});
282
- }
283
- }, [connected, info, loading, loadInfo, loadTables]);
309
+ useAutoLoadOnce(connected, "db-info", info != null || loading, () => {
310
+ loadInfo().catch(() => {});
311
+ loadTables().catch(() => {});
312
+ });
284
313
 
285
314
  return useMemo(
286
315
  () => ({ info, tables, loading, error, loadInfo, loadTables, dropTable, select, insert, update, remove, kvGet, kvSet, kvDelete, kvEntries }),
@@ -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";
@@ -0,0 +1,349 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockApplyConsoleEvent = vi.fn(async () => undefined);
4
+
5
+ vi.mock("../persistence/idb-store.js", () => ({
6
+ applyConsoleEvent: (...args: unknown[]) => mockApplyConsoleEvent(...args),
7
+ }));
8
+
9
+ import { WebSocketManager } from "./manager.js";
10
+ import { ConnectionState, type WebSocketMessage } from "./types.js";
11
+
12
+ function mockStorage(map: Record<string, string> = {}): Storage {
13
+ return {
14
+ getItem: (key: string) => map[key] ?? null,
15
+ setItem: (key: string, value: string) => {
16
+ map[key] = value;
17
+ },
18
+ removeItem: (key: string) => {
19
+ delete map[key];
20
+ },
21
+ clear: () => {
22
+ for (const key of Object.keys(map)) delete map[key];
23
+ },
24
+ key: () => null,
25
+ length: 0,
26
+ };
27
+ }
28
+
29
+ function installBrowserGlobals(storage: Storage = mockStorage()) {
30
+ const win = {
31
+ location: { origin: "http://localhost:5173", protocol: "http:", host: "localhost:5173" },
32
+ __ZHIN_API_TOKEN: undefined as string | undefined,
33
+ dispatchEvent: vi.fn(),
34
+ };
35
+ vi.stubGlobal("window", win);
36
+ vi.stubGlobal("localStorage", storage);
37
+ vi.stubGlobal("sessionStorage", mockStorage());
38
+ return { win, storage };
39
+ }
40
+
41
+ function sseResponse(status = 200, body?: ReadableStream<Uint8Array> | null) {
42
+ return {
43
+ ok: status >= 200 && status < 300,
44
+ status,
45
+ body: body === undefined
46
+ ? new ReadableStream<Uint8Array>({
47
+ start(controller) {
48
+ // Keep the stream open until abort; tests control lifecycle via disconnect().
49
+ void controller;
50
+ },
51
+ })
52
+ : body,
53
+ json: async () => ({}),
54
+ } as Response;
55
+ }
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
+
83
+ describe("WebSocketManager REST/SSE transport", () => {
84
+ beforeEach(() => {
85
+ vi.useFakeTimers();
86
+ mockApplyConsoleEvent.mockClear();
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.useRealTimers();
91
+ vi.unstubAllGlobals();
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
96
+ installBrowserGlobals();
97
+ const fetchMock = mockConsoleTransport(() => sseResponse(200));
98
+ vi.stubGlobal("fetch", fetchMock);
99
+
100
+ const manager = new WebSocketManager({ reconnectInterval: 100, maxReconnectAttempts: 3 });
101
+ const states: boolean[] = [];
102
+ manager.onConnectionChange((c) => states.push(c));
103
+ manager.connect();
104
+
105
+ await vi.advanceTimersByTimeAsync(0);
106
+ await Promise.resolve();
107
+ await Promise.resolve();
108
+
109
+ expect(fetchMock).toHaveBeenCalled();
110
+ const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
111
+ const init = sseCall[1] as RequestInit;
112
+ const headers = init.headers as Record<string, string>;
113
+ expect(headers.Accept).toBe("text/event-stream");
114
+ expect(headers.Authorization).toBeUndefined();
115
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
116
+ expect(manager.isConnected()).toBe(true);
117
+ manager.disconnect();
118
+ });
119
+
120
+ it("sends Authorization when a token is stored", async () => {
121
+ installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
122
+ const fetchMock = mockConsoleTransport(() => sseResponse(200));
123
+ vi.stubGlobal("fetch", fetchMock);
124
+
125
+ const manager = new WebSocketManager();
126
+ manager.connect();
127
+ await vi.advanceTimersByTimeAsync(0);
128
+ await Promise.resolve();
129
+ await Promise.resolve();
130
+
131
+ const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
132
+ const init = sseCall[1] as RequestInit;
133
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
134
+ manager.disconnect();
135
+ });
136
+
137
+ it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
138
+ installBrowserGlobals();
139
+ let sseCalls = 0;
140
+ const fetchMock = mockConsoleTransport(() => {
141
+ sseCalls += 1;
142
+ if (sseCalls === 1) return sseResponse(503, null);
143
+ return sseResponse(200);
144
+ });
145
+ vi.stubGlobal("fetch", fetchMock);
146
+
147
+ const manager = new WebSocketManager({
148
+ reconnectInterval: 100,
149
+ maxReconnectAttempts: 5,
150
+ });
151
+ manager.connect();
152
+
153
+ // First attempt fails → RECONNECTING
154
+ await vi.advanceTimersByTimeAsync(0);
155
+ await Promise.resolve();
156
+ await Promise.resolve();
157
+ expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
158
+ expect(sseCalls).toBe(1);
159
+
160
+ // scheduleReconnect delay = 100 * attempt(1) = 100ms
161
+ await vi.advanceTimersByTimeAsync(100);
162
+ await Promise.resolve();
163
+ await Promise.resolve();
164
+
165
+ expect(sseCalls).toBeGreaterThanOrEqual(2);
166
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
167
+ manager.disconnect();
168
+ });
169
+
170
+ it("sendRequest posts without Authorization when no token is stored", async () => {
171
+ installBrowserGlobals();
172
+ const fetchMock = vi.fn(async () => ({
173
+ ok: true,
174
+ status: 200,
175
+ json: async () => ({ success: true, data: { yaml: "http:\n port: 1\n" } }),
176
+ } as Response));
177
+ vi.stubGlobal("fetch", fetchMock);
178
+
179
+ const manager = new WebSocketManager();
180
+ const data = await manager.getConfigYaml();
181
+ expect(data.yaml).toContain("port");
182
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
183
+ expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
184
+ expect(init.method).toBe("POST");
185
+ });
186
+
187
+ it("sendRequest includes Bearer token when present", async () => {
188
+ installBrowserGlobals(mockStorage({ zhin_api_token: "t" }));
189
+ const fetchMock = vi.fn(async () => ({
190
+ ok: true,
191
+ status: 200,
192
+ json: async () => ({ success: true, data: { ok: true } }),
193
+ } as Response));
194
+ vi.stubGlobal("fetch", fetchMock);
195
+
196
+ const manager = new WebSocketManager();
197
+ await manager.setConfig("sandbox", { endpoints: [] });
198
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
199
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer t");
200
+ });
201
+
202
+ it("normalizes legacy endpoint push names and payload aliases before callbacks", async () => {
203
+ installBrowserGlobals();
204
+ const received: WebSocketMessage[] = [];
205
+ const manager = new WebSocketManager({}, {
206
+ onMessage: (message) => received.push(message),
207
+ });
208
+
209
+ (manager as unknown as { handleMessage(event: MessageEvent): void }).handleMessage({
210
+ data: '{"type":"endpoint:message","data":{"$adapter":"sandbox","endpoint":"bot"}}',
211
+ } as MessageEvent);
212
+ await Promise.resolve();
213
+
214
+ expect(received).toEqual([expect.objectContaining({
215
+ type: "message.receive",
216
+ data: expect.objectContaining({ adapter: "sandbox", endpointKey: "bot" }),
217
+ })]);
218
+ expect(mockApplyConsoleEvent).toHaveBeenCalledWith(expect.objectContaining({
219
+ type: "message.receive",
220
+ }));
221
+ });
222
+
223
+ it("stops reconnecting after maxReconnectAttempts", async () => {
224
+ installBrowserGlobals();
225
+ const fetchMock = mockConsoleTransport(() => sseResponse(500, null));
226
+ vi.stubGlobal("fetch", fetchMock);
227
+
228
+ const manager = new WebSocketManager({
229
+ reconnectInterval: 10,
230
+ maxReconnectAttempts: 2,
231
+ });
232
+ manager.connect();
233
+
234
+ for (let i = 0; i < 6; i++) {
235
+ await vi.advanceTimersByTimeAsync(50);
236
+ await Promise.resolve();
237
+ await Promise.resolve();
238
+ }
239
+
240
+ expect(manager.getState()).toBe(ConnectionState.ERROR);
241
+ const sseCalls = fetchMock.mock.calls.filter(([input]) => !String(input).includes('/history'));
242
+ expect(sseCalls.length).toBeLessThanOrEqual(3);
243
+ manager.disconnect();
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
+ });
349
+ });