expo-openclaw-chat 0.1.0
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/LICENSE +21 -0
- package/README.md +153 -0
- package/lib/index.ts +2 -0
- package/package.json +87 -0
- package/src/chat/__tests__/engine.test.ts +289 -0
- package/src/chat/engine.ts +582 -0
- package/src/chat/index.ts +5 -0
- package/src/core/__tests__/client.test.ts +305 -0
- package/src/core/__tests__/device-identity.test.ts +97 -0
- package/src/core/__tests__/protocol.test.ts +54 -0
- package/src/core/__tests__/storage.test.ts +81 -0
- package/src/core/client.ts +1019 -0
- package/src/core/device-identity.ts +176 -0
- package/src/core/index.ts +65 -0
- package/src/core/protocol.ts +377 -0
- package/src/core/storage.ts +43 -0
- package/src/createChat.tsx +145 -0
- package/src/index.ts +41 -0
- package/src/ui/ChatBubble.tsx +238 -0
- package/src/ui/ChatInput.tsx +369 -0
- package/src/ui/ChatList.tsx +107 -0
- package/src/ui/ChatModal.tsx +343 -0
- package/src/ui/index.ts +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Bruno Barbieri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# 🦞 expo-openclaw-chat 🦞
|
|
2
|
+
|
|
3
|
+
Minimal chat SDK for Expo apps to connect to OpenClaw gateway.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="screenshot.png" width="300" alt="Demo Chat Screenshot" />
|
|
7
|
+
<br/>
|
|
8
|
+
<em>A picture is worth a thousand words.</em>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install expo-openclaw-chat
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { createChat } from "expo-openclaw-chat";
|
|
21
|
+
|
|
22
|
+
// Create chat instance
|
|
23
|
+
const chat = createChat({
|
|
24
|
+
gatewayUrl: "wss://your-gateway.example.com",
|
|
25
|
+
token: "your-auth-token", // or use password/deviceToken
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Wrap your app with ChatProvider
|
|
29
|
+
function App() {
|
|
30
|
+
return (
|
|
31
|
+
<chat.ChatProvider>
|
|
32
|
+
<YourApp />
|
|
33
|
+
</chat.ChatProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Open chat modal from anywhere
|
|
38
|
+
chat.open();
|
|
39
|
+
|
|
40
|
+
// Close chat modal
|
|
41
|
+
chat.close();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration Options
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
createChat({
|
|
48
|
+
// Required
|
|
49
|
+
gatewayUrl: string; // WebSocket URL (wss:// or ws://)
|
|
50
|
+
|
|
51
|
+
// Authentication (pick one)
|
|
52
|
+
token?: string; // Simple auth token
|
|
53
|
+
password?: string; // Password auth
|
|
54
|
+
deviceToken?: string; // Device token from pairing
|
|
55
|
+
|
|
56
|
+
// Optional
|
|
57
|
+
sessionKey?: string; // Chat session key (auto-generated if not provided)
|
|
58
|
+
title?: string; // Modal title (default: "Chat")
|
|
59
|
+
placeholder?: string; // Input placeholder text
|
|
60
|
+
showImagePicker?: boolean; // Show image picker button (requires expo-image-picker)
|
|
61
|
+
clientId?: string; // Client ID for gateway registration
|
|
62
|
+
|
|
63
|
+
// Callbacks
|
|
64
|
+
onOpen?: () => void; // Called when modal opens
|
|
65
|
+
onClose?: () => void; // Called when modal closes
|
|
66
|
+
|
|
67
|
+
// Storage
|
|
68
|
+
storage?: Storage; // Custom storage for device identity
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Optional Dependencies
|
|
73
|
+
|
|
74
|
+
Install these for additional features:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Image attachments
|
|
78
|
+
npm install expo-image-picker
|
|
79
|
+
|
|
80
|
+
# Markdown rendering
|
|
81
|
+
npm install react-native-marked
|
|
82
|
+
|
|
83
|
+
# Persistent device identity (recommended)
|
|
84
|
+
npm install react-native-mmkv
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Custom Storage
|
|
88
|
+
|
|
89
|
+
By default, device identity is stored in memory (regenerates on app restart). For persistent identity:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { setStorage } from "expo-openclaw-chat/lib";
|
|
93
|
+
import { MMKV } from "react-native-mmkv";
|
|
94
|
+
|
|
95
|
+
const storage = new MMKV({ id: "my-app" });
|
|
96
|
+
setStorage(storage);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Advanced Usage
|
|
100
|
+
|
|
101
|
+
### Using Individual Components
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
import {
|
|
105
|
+
GatewayClient,
|
|
106
|
+
ChatEngine,
|
|
107
|
+
ChatModal,
|
|
108
|
+
ChatList,
|
|
109
|
+
ChatBubble,
|
|
110
|
+
ChatInput
|
|
111
|
+
} from "expo-openclaw-chat";
|
|
112
|
+
|
|
113
|
+
// Create client manually
|
|
114
|
+
const client = new GatewayClient("wss://gateway.example.com", {
|
|
115
|
+
token: "your-token",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Connect
|
|
119
|
+
await client.connect();
|
|
120
|
+
|
|
121
|
+
// Create chat engine
|
|
122
|
+
const engine = new ChatEngine(client, "session-key");
|
|
123
|
+
|
|
124
|
+
// Listen for updates
|
|
125
|
+
engine.on("update", () => {
|
|
126
|
+
console.log("Messages:", engine.messages);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Send a message
|
|
130
|
+
await engine.send("Hello!");
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Direct Core Access
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import {
|
|
137
|
+
GatewayClient,
|
|
138
|
+
generateIdempotencyKey,
|
|
139
|
+
loadOrCreateIdentity,
|
|
140
|
+
} from "expo-openclaw-chat/lib";
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Demo App
|
|
144
|
+
|
|
145
|
+
See the [demo](./demo) folder for a complete example.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
https://github.com/user-attachments/assets/69316dca-5bbf-42b7-93b9-fa9bc3bbc5f5
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
package/lib/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-openclaw-chat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal chat SDK for Expo apps to connect to OpenClaw gateway",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./lib": "./lib/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"lib"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "jest"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@noble/ed25519": "^3.0.0",
|
|
21
|
+
"@noble/hashes": "^2.0.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"expo": ">=50.0.0",
|
|
25
|
+
"react": ">=18.0.0",
|
|
26
|
+
"react-native": ">=0.72.0",
|
|
27
|
+
"react-native-keyboard-controller": ">=1.0.0",
|
|
28
|
+
"react-native-reanimated": ">=3.0.0",
|
|
29
|
+
"react-native-safe-area-context": ">=4.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"expo-image-picker": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"react-native-marked": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"react-native-mmkv": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@babel/core": "^7.24.0",
|
|
44
|
+
"@babel/preset-env": "^7.24.0",
|
|
45
|
+
"@types/jest": "^29.5.0",
|
|
46
|
+
"@types/react": "^19.2.10",
|
|
47
|
+
"@types/react-native": "^0.72.8",
|
|
48
|
+
"babel-jest": "^29.7.0",
|
|
49
|
+
"jest": "^29.7.0",
|
|
50
|
+
"react-native-keyboard-controller": "^1.20.7",
|
|
51
|
+
"react-native-reanimated": "^4.2.1",
|
|
52
|
+
"react-native-safe-area-context": "^5.6.2",
|
|
53
|
+
"ts-jest": "^29.1.0",
|
|
54
|
+
"typescript": "^5.3.0"
|
|
55
|
+
},
|
|
56
|
+
"jest": {
|
|
57
|
+
"preset": "ts-jest",
|
|
58
|
+
"testEnvironment": "node",
|
|
59
|
+
"testMatch": [
|
|
60
|
+
"**/__tests__/**/*.test.ts"
|
|
61
|
+
],
|
|
62
|
+
"moduleFileExtensions": [
|
|
63
|
+
"ts",
|
|
64
|
+
"js"
|
|
65
|
+
],
|
|
66
|
+
"transformIgnorePatterns": [
|
|
67
|
+
"node_modules/(?!(@noble)/)"
|
|
68
|
+
],
|
|
69
|
+
"transform": {
|
|
70
|
+
"^.+\\.ts$": "ts-jest",
|
|
71
|
+
"^.+\\.js$": "babel-jest"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"keywords": [
|
|
75
|
+
"expo",
|
|
76
|
+
"react-native",
|
|
77
|
+
"chat",
|
|
78
|
+
"openclaw",
|
|
79
|
+
"ai"
|
|
80
|
+
],
|
|
81
|
+
"author": "Bruno Barbieri",
|
|
82
|
+
"license": "MIT",
|
|
83
|
+
"repository": {
|
|
84
|
+
"type": "git",
|
|
85
|
+
"url": "https://github.com/anthropics/expo-openclaw-chat"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ChatEngine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ChatEngine, type PendingAttachment } from "../engine";
|
|
6
|
+
import type { GatewayClient } from "../../core/client";
|
|
7
|
+
import type { ChatEventPayload } from "../../core/protocol";
|
|
8
|
+
|
|
9
|
+
// Mock client type with test helpers
|
|
10
|
+
type MockGatewayClient = GatewayClient & {
|
|
11
|
+
_emitConnectionState: (state: string) => void;
|
|
12
|
+
_emitChatEvent: (payload: ChatEventPayload) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Mock GatewayClient
|
|
16
|
+
function createMockClient(overrides: Partial<GatewayClient> = {}): MockGatewayClient {
|
|
17
|
+
const connectionStateListeners = new Set<(state: string) => void>();
|
|
18
|
+
const chatEventListeners = new Set<(payload: ChatEventPayload) => void>();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
isConnected: true,
|
|
22
|
+
connectionState: "connected",
|
|
23
|
+
serverInfo: null,
|
|
24
|
+
|
|
25
|
+
onConnectionStateChange: jest.fn((cb) => {
|
|
26
|
+
connectionStateListeners.add(cb);
|
|
27
|
+
return () => connectionStateListeners.delete(cb);
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
onChatEvent: jest.fn((cb) => {
|
|
31
|
+
chatEventListeners.add(cb);
|
|
32
|
+
return () => chatEventListeners.delete(cb);
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
chatSubscribe: jest.fn(),
|
|
36
|
+
chatSend: jest.fn().mockResolvedValue({ runId: "test-run-id" }),
|
|
37
|
+
chatAbort: jest.fn().mockResolvedValue(undefined),
|
|
38
|
+
|
|
39
|
+
// Test helpers
|
|
40
|
+
_emitConnectionState: (state: string) => {
|
|
41
|
+
connectionStateListeners.forEach((cb) => cb(state));
|
|
42
|
+
},
|
|
43
|
+
_emitChatEvent: (payload: ChatEventPayload) => {
|
|
44
|
+
chatEventListeners.forEach((cb) => cb(payload));
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
...overrides,
|
|
48
|
+
} as unknown as MockGatewayClient;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("ChatEngine", () => {
|
|
52
|
+
let mockClient: MockGatewayClient;
|
|
53
|
+
let engine: ChatEngine;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
mockClient = createMockClient();
|
|
57
|
+
engine = new ChatEngine(mockClient as unknown as GatewayClient, "test-session");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
engine.destroy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("constructor", () => {
|
|
65
|
+
it("initializes with empty messages", () => {
|
|
66
|
+
expect(engine.messages).toEqual([]);
|
|
67
|
+
expect(engine.isStreaming).toBe(false);
|
|
68
|
+
expect(engine.error).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("subscribes to chat events for the session", () => {
|
|
72
|
+
expect(mockClient.chatSubscribe).toHaveBeenCalledWith("test-session");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("send", () => {
|
|
77
|
+
it("adds a user message to the list", async () => {
|
|
78
|
+
await engine.send("Hello, world!");
|
|
79
|
+
|
|
80
|
+
expect(engine.messages.length).toBe(2); // user + assistant placeholder
|
|
81
|
+
const userMessage = engine.messages[0];
|
|
82
|
+
expect(userMessage).toBeDefined();
|
|
83
|
+
expect(userMessage!.role).toBe("user");
|
|
84
|
+
expect(userMessage!.content).toEqual([{ type: "text", text: "Hello, world!" }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("calls chatSend on the client", async () => {
|
|
88
|
+
await engine.send("Test message");
|
|
89
|
+
|
|
90
|
+
expect(mockClient.chatSend).toHaveBeenCalledWith(
|
|
91
|
+
"test-session",
|
|
92
|
+
"Test message",
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
idempotencyKey: expect.any(String),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not send empty messages", async () => {
|
|
100
|
+
await engine.send("");
|
|
101
|
+
await engine.send(" ");
|
|
102
|
+
|
|
103
|
+
expect(mockClient.chatSend).not.toHaveBeenCalled();
|
|
104
|
+
expect(engine.messages).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("handles attachments", async () => {
|
|
108
|
+
const attachments: PendingAttachment[] = [
|
|
109
|
+
{
|
|
110
|
+
id: "img-1",
|
|
111
|
+
fileName: "test.jpg",
|
|
112
|
+
mimeType: "image/jpeg",
|
|
113
|
+
content: "base64data",
|
|
114
|
+
type: "image",
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
await engine.send("With image", attachments);
|
|
119
|
+
|
|
120
|
+
const message = engine.messages[0];
|
|
121
|
+
expect(message).toBeDefined();
|
|
122
|
+
expect(message!.content.length).toBe(2); // image + text
|
|
123
|
+
expect(message!.content[0]!.type).toBe("image");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sets error when not connected", async () => {
|
|
127
|
+
const disconnectedClient = createMockClient({ isConnected: false });
|
|
128
|
+
const disconnectedEngine = new ChatEngine(
|
|
129
|
+
disconnectedClient as unknown as GatewayClient,
|
|
130
|
+
"test-session",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await disconnectedEngine.send("Test");
|
|
134
|
+
|
|
135
|
+
expect(disconnectedEngine.error).not.toBeNull();
|
|
136
|
+
expect(disconnectedEngine.error?.message).toBe("Not connected");
|
|
137
|
+
|
|
138
|
+
disconnectedEngine.destroy();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("abort", () => {
|
|
143
|
+
it("calls chatAbort on the client", async () => {
|
|
144
|
+
await engine.send("Test");
|
|
145
|
+
await engine.abort();
|
|
146
|
+
|
|
147
|
+
expect(mockClient.chatAbort).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does nothing when not streaming", async () => {
|
|
151
|
+
await engine.abort();
|
|
152
|
+
|
|
153
|
+
expect(mockClient.chatAbort).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("clear", () => {
|
|
158
|
+
it("removes all messages", async () => {
|
|
159
|
+
await engine.send("Message 1");
|
|
160
|
+
engine.clear();
|
|
161
|
+
|
|
162
|
+
expect(engine.messages).toEqual([]);
|
|
163
|
+
expect(engine.isStreaming).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("event handling", () => {
|
|
168
|
+
it("handles delta events", async () => {
|
|
169
|
+
await engine.send("Test");
|
|
170
|
+
|
|
171
|
+
// Simulate streaming delta
|
|
172
|
+
mockClient._emitChatEvent({
|
|
173
|
+
runId: "test-run-id",
|
|
174
|
+
sessionKey: "test-session",
|
|
175
|
+
state: "delta",
|
|
176
|
+
message: {
|
|
177
|
+
role: "assistant",
|
|
178
|
+
content: [{ type: "text", text: "Partial response" }],
|
|
179
|
+
},
|
|
180
|
+
} as ChatEventPayload);
|
|
181
|
+
|
|
182
|
+
// Wait for flush timer
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
184
|
+
|
|
185
|
+
const assistantMsg = engine.messages.find((m) => m.role === "assistant");
|
|
186
|
+
expect(assistantMsg).toBeDefined();
|
|
187
|
+
expect(assistantMsg?.isStreaming).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("handles complete events", async () => {
|
|
191
|
+
await engine.send("Test");
|
|
192
|
+
|
|
193
|
+
// Simulate complete
|
|
194
|
+
mockClient._emitChatEvent({
|
|
195
|
+
runId: "test-run-id",
|
|
196
|
+
sessionKey: "test-session",
|
|
197
|
+
state: "complete",
|
|
198
|
+
message: {
|
|
199
|
+
role: "assistant",
|
|
200
|
+
content: [{ type: "text", text: "Final response" }],
|
|
201
|
+
},
|
|
202
|
+
} as ChatEventPayload);
|
|
203
|
+
|
|
204
|
+
const assistantMsg = engine.messages.find((m) => m.role === "assistant");
|
|
205
|
+
expect(assistantMsg?.isStreaming).toBe(false);
|
|
206
|
+
expect(engine.isStreaming).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("handles error events", async () => {
|
|
210
|
+
await engine.send("Test");
|
|
211
|
+
|
|
212
|
+
mockClient._emitChatEvent({
|
|
213
|
+
runId: "test-run-id",
|
|
214
|
+
sessionKey: "test-session",
|
|
215
|
+
state: "error",
|
|
216
|
+
errorMessage: "Something went wrong",
|
|
217
|
+
} as ChatEventPayload);
|
|
218
|
+
|
|
219
|
+
expect(engine.error?.message).toBe("Something went wrong");
|
|
220
|
+
expect(engine.isStreaming).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("ignores events for other sessions", async () => {
|
|
224
|
+
await engine.send("Test");
|
|
225
|
+
|
|
226
|
+
mockClient._emitChatEvent({
|
|
227
|
+
runId: "other-run-id",
|
|
228
|
+
sessionKey: "other-session",
|
|
229
|
+
state: "delta",
|
|
230
|
+
message: {
|
|
231
|
+
role: "assistant",
|
|
232
|
+
content: [{ type: "text", text: "Wrong session" }],
|
|
233
|
+
},
|
|
234
|
+
} as ChatEventPayload);
|
|
235
|
+
|
|
236
|
+
// Should only have user message + placeholder
|
|
237
|
+
expect(engine.messages.length).toBe(2);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("event subscriptions", () => {
|
|
242
|
+
it("notifies update listeners", async () => {
|
|
243
|
+
const updates: number[] = [];
|
|
244
|
+
engine.on("update", () => updates.push(engine.messages.length));
|
|
245
|
+
|
|
246
|
+
await engine.send("Test");
|
|
247
|
+
|
|
248
|
+
expect(updates.length).toBeGreaterThan(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("notifies error listeners", async () => {
|
|
252
|
+
const errors: Error[] = [];
|
|
253
|
+
engine.on("error", (err) => errors.push(err));
|
|
254
|
+
|
|
255
|
+
// Trigger an error
|
|
256
|
+
mockClient._emitChatEvent({
|
|
257
|
+
runId: "test-run-id",
|
|
258
|
+
sessionKey: "test-session",
|
|
259
|
+
state: "error",
|
|
260
|
+
errorMessage: "Test error",
|
|
261
|
+
} as ChatEventPayload);
|
|
262
|
+
|
|
263
|
+
expect(errors.length).toBe(1);
|
|
264
|
+
expect(errors[0]!.message).toBe("Test error");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("allows unsubscribing", () => {
|
|
268
|
+
const updates: number[] = [];
|
|
269
|
+
const unsub = engine.on("update", () => updates.push(1));
|
|
270
|
+
|
|
271
|
+
unsub();
|
|
272
|
+
engine.clear();
|
|
273
|
+
|
|
274
|
+
expect(updates).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("destroy", () => {
|
|
279
|
+
it("cleans up subscriptions", () => {
|
|
280
|
+
const updates: number[] = [];
|
|
281
|
+
engine.on("update", () => updates.push(1));
|
|
282
|
+
|
|
283
|
+
engine.destroy();
|
|
284
|
+
engine.clear(); // This would normally trigger update
|
|
285
|
+
|
|
286
|
+
expect(updates).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|