@openclaw-cloud/agent-controller 0.1.4 → 0.1.6
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/.husky/pre-commit +1 -0
- package/BIZPLAN.md +530 -0
- package/CLAUDE.md +64 -0
- package/__tests__/api.test.ts +123 -0
- package/__tests__/chat.test.ts +191 -0
- package/__tests__/config.test.ts +100 -0
- package/__tests__/connection.test.ts +171 -14
- package/__tests__/file-delete.test.ts +90 -0
- package/__tests__/file-write.test.ts +119 -0
- package/__tests__/gateway-adapter.test.ts +366 -0
- package/__tests__/gateway-client.test.ts +272 -0
- package/__tests__/onboarding.test.ts +55 -0
- package/__tests__/package-install.test.ts +109 -0
- package/__tests__/pair.test.ts +60 -0
- package/__tests__/self-update.test.ts +123 -0
- package/__tests__/stop.test.ts +38 -0
- package/bin/agent-controller.js +15 -2
- package/dist/commands/self-update.d.ts +1 -0
- package/dist/commands/self-update.js +41 -0
- package/dist/commands/self-update.js.map +1 -0
- package/dist/connection.d.ts +1 -1
- package/dist/connection.js +49 -29
- package/dist/connection.js.map +1 -1
- package/dist/handlers/board-handler.d.ts +1 -0
- package/dist/handlers/board-handler.js +5 -2
- package/dist/handlers/board-handler.js.map +1 -1
- package/dist/handlers/chat.d.ts +4 -0
- package/dist/handlers/chat.js +62 -0
- package/dist/handlers/chat.js.map +1 -0
- package/dist/handlers/file-delete.d.ts +2 -0
- package/dist/handlers/file-delete.js +41 -0
- package/dist/handlers/file-delete.js.map +1 -0
- package/dist/handlers/file-write.d.ts +2 -0
- package/dist/handlers/file-write.js +59 -0
- package/dist/handlers/file-write.js.map +1 -0
- package/dist/handlers/onboarding.d.ts +2 -0
- package/dist/handlers/onboarding.js +18 -0
- package/dist/handlers/onboarding.js.map +1 -0
- package/dist/handlers/package-install.d.ts +2 -0
- package/dist/handlers/package-install.js +59 -0
- package/dist/handlers/package-install.js.map +1 -0
- package/dist/index.js +20 -7
- package/dist/index.js.map +1 -1
- package/dist/openclaw/gateway-adapter.d.ts +22 -0
- package/dist/openclaw/gateway-adapter.js +108 -0
- package/dist/openclaw/gateway-adapter.js.map +1 -0
- package/dist/openclaw/gateway-client.d.ts +21 -0
- package/dist/openclaw/gateway-client.js +114 -0
- package/dist/openclaw/gateway-client.js.map +1 -0
- package/dist/openclaw/index.d.ts +8 -0
- package/dist/openclaw/index.js +12 -0
- package/dist/openclaw/index.js.map +1 -0
- package/dist/openclaw/types.d.ts +36 -0
- package/dist/openclaw/types.js +2 -0
- package/dist/openclaw/types.js.map +1 -0
- package/dist/types.d.ts +2 -1
- package/package.json +4 -2
- package/src/commands/self-update.ts +43 -0
- package/src/connection.ts +31 -28
- package/src/handlers/file-delete.ts +46 -0
- package/src/handlers/file-write.ts +65 -0
- package/src/handlers/onboarding.ts +19 -0
- package/src/handlers/package-install.ts +69 -0
- package/src/index.ts +3 -5
- package/src/types.ts +2 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { handleFileDelete } from '../src/handlers/file-delete';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
jest.mock('node:fs/promises');
|
|
6
|
+
|
|
7
|
+
const mockUnlink = fs.unlink as jest.Mock;
|
|
8
|
+
|
|
9
|
+
const baseCmd: AgentCommand = { id: '1', type: 'file_delete', payload: {} };
|
|
10
|
+
|
|
11
|
+
describe('handleFileDelete', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
mockUnlink.mockResolvedValue(undefined);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('deletes file and returns success', async () => {
|
|
18
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'config/agent.yml' } };
|
|
19
|
+
const res = await handleFileDelete(cmd);
|
|
20
|
+
expect(res.success).toBe(true);
|
|
21
|
+
expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('agent.yml'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns resolved path in data on success', async () => {
|
|
25
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'some/file.txt' } };
|
|
26
|
+
const res = await handleFileDelete(cmd);
|
|
27
|
+
expect(res.success).toBe(true);
|
|
28
|
+
expect(res.data?.path).toEqual(expect.stringContaining('file.txt'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects path traversal with ".."', async () => {
|
|
32
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: '../etc/passwd' } };
|
|
33
|
+
const res = await handleFileDelete(cmd);
|
|
34
|
+
expect(res.success).toBe(false);
|
|
35
|
+
expect(res.error).toContain('Invalid path');
|
|
36
|
+
expect(mockUnlink).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects nested path traversal (foo/../bar)', async () => {
|
|
40
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'foo/../bar/secret' } };
|
|
41
|
+
const res = await handleFileDelete(cmd);
|
|
42
|
+
expect(res.success).toBe(false);
|
|
43
|
+
expect(res.error).toContain('Invalid path');
|
|
44
|
+
expect(mockUnlink).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects absolute path starting with "/"', async () => {
|
|
48
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: '/etc/passwd' } };
|
|
49
|
+
const res = await handleFileDelete(cmd);
|
|
50
|
+
expect(res.success).toBe(false);
|
|
51
|
+
expect(res.error).toContain('Invalid path');
|
|
52
|
+
expect(mockUnlink).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns error when path is missing', async () => {
|
|
56
|
+
const cmd: AgentCommand = { ...baseCmd, payload: {} };
|
|
57
|
+
const res = await handleFileDelete(cmd);
|
|
58
|
+
expect(res.success).toBe(false);
|
|
59
|
+
expect(res.error).toContain('Missing');
|
|
60
|
+
expect(mockUnlink).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns error when unlink fails (file not found)', async () => {
|
|
64
|
+
mockUnlink.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
|
65
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'missing.txt' } };
|
|
66
|
+
const res = await handleFileDelete(cmd);
|
|
67
|
+
expect(res.success).toBe(false);
|
|
68
|
+
expect(res.error).toContain('ENOENT');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns error when unlink fails (permission denied)', async () => {
|
|
72
|
+
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'));
|
|
73
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'protected.txt' } };
|
|
74
|
+
const res = await handleFileDelete(cmd);
|
|
75
|
+
expect(res.success).toBe(false);
|
|
76
|
+
expect(res.error).toContain('EACCES');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('response type is file_delete', async () => {
|
|
80
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'file.txt' } };
|
|
81
|
+
const res = await handleFileDelete(cmd);
|
|
82
|
+
expect(res.type).toBe('file_delete');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('response id matches command id', async () => {
|
|
86
|
+
const cmd: AgentCommand = { ...baseCmd, id: 'unique-cmd-id', payload: { path: 'file.txt' } };
|
|
87
|
+
const res = await handleFileDelete(cmd);
|
|
88
|
+
expect(res.id).toBe('unique-cmd-id');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { handleFileWrite } from '../src/handlers/file-write';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
jest.mock('node:fs/promises');
|
|
6
|
+
|
|
7
|
+
const mockMkdir = fs.mkdir as jest.Mock;
|
|
8
|
+
const mockWriteFile = fs.writeFile as jest.Mock;
|
|
9
|
+
const mockAccess = fs.access as jest.Mock;
|
|
10
|
+
|
|
11
|
+
const baseCmd: AgentCommand = { id: '1', type: 'file_write', payload: {} };
|
|
12
|
+
|
|
13
|
+
describe('handleFileWrite', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
jest.clearAllMocks();
|
|
16
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
17
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('writes file and returns written:true', async () => {
|
|
21
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'config/agent.yml', content: 'key: val' } };
|
|
22
|
+
const res = await handleFileWrite(cmd);
|
|
23
|
+
expect(res.success).toBe(true);
|
|
24
|
+
expect(res.data?.written).toBe(true);
|
|
25
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
26
|
+
expect.stringContaining('agent.yml'),
|
|
27
|
+
'key: val',
|
|
28
|
+
'utf-8',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('skips write when overwrite=false and file already exists', async () => {
|
|
33
|
+
mockAccess.mockResolvedValue(undefined); // file exists
|
|
34
|
+
const cmd: AgentCommand = {
|
|
35
|
+
...baseCmd,
|
|
36
|
+
payload: { path: 'config/agent.yml', content: 'key: val', overwrite: false },
|
|
37
|
+
};
|
|
38
|
+
const res = await handleFileWrite(cmd);
|
|
39
|
+
expect(res.success).toBe(true);
|
|
40
|
+
expect(res.data?.written).toBe(false);
|
|
41
|
+
expect(res.data?.skipped).toBe(true);
|
|
42
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('writes when overwrite=false and file does not exist', async () => {
|
|
46
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
47
|
+
const cmd: AgentCommand = {
|
|
48
|
+
...baseCmd,
|
|
49
|
+
payload: { path: 'config/new.yml', content: 'data', overwrite: false },
|
|
50
|
+
};
|
|
51
|
+
const res = await handleFileWrite(cmd);
|
|
52
|
+
expect(res.success).toBe(true);
|
|
53
|
+
expect(res.data?.written).toBe(true);
|
|
54
|
+
expect(mockWriteFile).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects path traversal with ".."', async () => {
|
|
58
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: '../etc/passwd', content: 'root' } };
|
|
59
|
+
const res = await handleFileWrite(cmd);
|
|
60
|
+
expect(res.success).toBe(false);
|
|
61
|
+
expect(res.error).toContain('Invalid path');
|
|
62
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects nested path traversal (foo/../bar)', async () => {
|
|
66
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'foo/../bar/secret', content: 'x' } };
|
|
67
|
+
const res = await handleFileWrite(cmd);
|
|
68
|
+
expect(res.success).toBe(false);
|
|
69
|
+
expect(res.error).toContain('Invalid path');
|
|
70
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects absolute path starting with "/"', async () => {
|
|
74
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: '/etc/passwd', content: 'root' } };
|
|
75
|
+
const res = await handleFileWrite(cmd);
|
|
76
|
+
expect(res.success).toBe(false);
|
|
77
|
+
expect(res.error).toContain('Invalid path');
|
|
78
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('creates parent directories before writing', async () => {
|
|
82
|
+
const cmd: AgentCommand = {
|
|
83
|
+
...baseCmd,
|
|
84
|
+
payload: { path: 'deep/nested/dir/file.txt', content: 'hello' },
|
|
85
|
+
};
|
|
86
|
+
const res = await handleFileWrite(cmd);
|
|
87
|
+
expect(res.success).toBe(true);
|
|
88
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns error when path is missing', async () => {
|
|
92
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { content: 'data' } };
|
|
93
|
+
const res = await handleFileWrite(cmd);
|
|
94
|
+
expect(res.success).toBe(false);
|
|
95
|
+
expect(res.error).toContain('Missing');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns error when content is missing', async () => {
|
|
99
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'file.txt' } };
|
|
100
|
+
const res = await handleFileWrite(cmd);
|
|
101
|
+
expect(res.success).toBe(false);
|
|
102
|
+
expect(res.error).toContain('Missing');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('allows empty string content', async () => {
|
|
106
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'empty.txt', content: '' } };
|
|
107
|
+
const res = await handleFileWrite(cmd);
|
|
108
|
+
expect(res.success).toBe(true);
|
|
109
|
+
expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), '', 'utf-8');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns error when writeFile fails', async () => {
|
|
113
|
+
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'));
|
|
114
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'file.txt', content: 'data' } };
|
|
115
|
+
const res = await handleFileWrite(cmd);
|
|
116
|
+
expect(res.success).toBe(false);
|
|
117
|
+
expect(res.error).toContain('EACCES');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Mock GatewayClient before importing gateway-adapter
|
|
2
|
+
jest.mock('../src/openclaw/gateway-client');
|
|
3
|
+
|
|
4
|
+
import { GatewayClient } from '../src/openclaw/gateway-client';
|
|
5
|
+
import { OpenclawGatewayAdapter } from '../src/openclaw/gateway-adapter';
|
|
6
|
+
|
|
7
|
+
const MockGatewayClient = GatewayClient as jest.MockedClass<typeof GatewayClient>;
|
|
8
|
+
|
|
9
|
+
type EventHandler = (event: { event: string; payload: any }) => void;
|
|
10
|
+
|
|
11
|
+
function makeAdapter(): {
|
|
12
|
+
adapter: OpenclawGatewayAdapter;
|
|
13
|
+
mock: jest.Mocked<GatewayClient>;
|
|
14
|
+
simulateStreamEvent: (payload: Record<string, unknown>) => void;
|
|
15
|
+
} {
|
|
16
|
+
const mock: jest.Mocked<GatewayClient> = {
|
|
17
|
+
connect: jest.fn().mockResolvedValue(undefined),
|
|
18
|
+
disconnect: jest.fn(),
|
|
19
|
+
isConnected: jest.fn().mockReturnValue(false),
|
|
20
|
+
request: jest.fn().mockResolvedValue(undefined),
|
|
21
|
+
setEventHandler: jest.fn(),
|
|
22
|
+
} as unknown as jest.Mocked<GatewayClient>;
|
|
23
|
+
|
|
24
|
+
MockGatewayClient.mockImplementation(() => mock);
|
|
25
|
+
|
|
26
|
+
const adapter = new OpenclawGatewayAdapter('ws://localhost', 'token');
|
|
27
|
+
|
|
28
|
+
// Capture the event handler registered in the constructor
|
|
29
|
+
const capturedHandler: EventHandler = mock.setEventHandler.mock.calls[0][0];
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
adapter,
|
|
33
|
+
mock,
|
|
34
|
+
simulateStreamEvent: (payload) => capturedHandler({ event: 'chat.stream', payload }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.clearAllMocks();
|
|
40
|
+
MockGatewayClient.mockClear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('OpenclawGatewayAdapter – construction', () => {
|
|
44
|
+
it('creates a GatewayClient and registers an event handler', () => {
|
|
45
|
+
const { mock } = makeAdapter();
|
|
46
|
+
expect(MockGatewayClient).toHaveBeenCalledTimes(1);
|
|
47
|
+
expect(mock.setEventHandler).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(mock.setEventHandler).toHaveBeenCalledWith(expect.any(Function));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('OpenclawGatewayAdapter – isConnected()', () => {
|
|
53
|
+
it('delegates to GatewayClient.isConnected()', () => {
|
|
54
|
+
const { adapter, mock } = makeAdapter();
|
|
55
|
+
mock.isConnected.mockReturnValueOnce(false);
|
|
56
|
+
expect(adapter.isConnected()).toBe(false);
|
|
57
|
+
mock.isConnected.mockReturnValueOnce(true);
|
|
58
|
+
expect(adapter.isConnected()).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('OpenclawGatewayAdapter – connect()', () => {
|
|
63
|
+
it('delegates to GatewayClient.connect()', async () => {
|
|
64
|
+
const { adapter, mock } = makeAdapter();
|
|
65
|
+
await adapter.connect();
|
|
66
|
+
expect(mock.connect).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('propagates rejection from GatewayClient.connect()', async () => {
|
|
70
|
+
const { adapter, mock } = makeAdapter();
|
|
71
|
+
mock.connect.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
72
|
+
await expect(adapter.connect()).rejects.toThrow('ECONNREFUSED');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('OpenclawGatewayAdapter – disconnect()', () => {
|
|
77
|
+
it('delegates to GatewayClient.disconnect()', () => {
|
|
78
|
+
const { adapter, mock } = makeAdapter();
|
|
79
|
+
adapter.disconnect();
|
|
80
|
+
expect(mock.disconnect).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('OpenclawGatewayAdapter – listSessions()', () => {
|
|
85
|
+
it('calls request("sessions.list", {}) and maps result', async () => {
|
|
86
|
+
const { adapter, mock } = makeAdapter();
|
|
87
|
+
mock.request.mockResolvedValue({
|
|
88
|
+
sessions: [
|
|
89
|
+
{ key: 's1', sessionId: 'sid-1', kind: 'direct', updatedAt: 1000, model: 'claude-3' },
|
|
90
|
+
{ key: 's2', sessionId: 'sid-2', kind: 'project', updatedAt: 2000 },
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await adapter.listSessions();
|
|
95
|
+
|
|
96
|
+
expect(mock.request).toHaveBeenCalledWith('sessions.list', {});
|
|
97
|
+
expect(result).toHaveLength(2);
|
|
98
|
+
expect(result[0]).toMatchObject({ key: 's1', sessionId: 'sid-1', kind: 'direct', updatedAt: 1000, model: 'claude-3' });
|
|
99
|
+
expect(result[1]).toMatchObject({ key: 's2', sessionId: 'sid-2', kind: 'project', updatedAt: 2000 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('uses default values for missing session fields', async () => {
|
|
103
|
+
const { adapter, mock } = makeAdapter();
|
|
104
|
+
mock.request.mockResolvedValue({ sessions: [{ key: 'k1' }] });
|
|
105
|
+
|
|
106
|
+
const result = await adapter.listSessions();
|
|
107
|
+
|
|
108
|
+
expect(result[0]).toMatchObject({ key: 'k1', sessionId: '', kind: 'direct', updatedAt: 0 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns [] when result is null', async () => {
|
|
112
|
+
const { adapter, mock } = makeAdapter();
|
|
113
|
+
mock.request.mockResolvedValue(null);
|
|
114
|
+
|
|
115
|
+
const result = await adapter.listSessions();
|
|
116
|
+
expect(result).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns [] when sessions field is missing', async () => {
|
|
120
|
+
const { adapter, mock } = makeAdapter();
|
|
121
|
+
mock.request.mockResolvedValue({});
|
|
122
|
+
|
|
123
|
+
const result = await adapter.listSessions();
|
|
124
|
+
expect(result).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('OpenclawGatewayAdapter – getHistory()', () => {
|
|
129
|
+
it('calls request("chat.history") with sessionKey and default limit 200', async () => {
|
|
130
|
+
const { adapter, mock } = makeAdapter();
|
|
131
|
+
mock.request.mockResolvedValue({ messages: [] });
|
|
132
|
+
|
|
133
|
+
await adapter.getHistory('sess-1');
|
|
134
|
+
|
|
135
|
+
expect(mock.request).toHaveBeenCalledWith('chat.history', { sessionKey: 'sess-1', limit: 200 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('passes custom limit when provided', async () => {
|
|
139
|
+
const { adapter, mock } = makeAdapter();
|
|
140
|
+
mock.request.mockResolvedValue({ messages: [] });
|
|
141
|
+
|
|
142
|
+
await adapter.getHistory('sess-1', 50);
|
|
143
|
+
|
|
144
|
+
expect(mock.request).toHaveBeenCalledWith('chat.history', { sessionKey: 'sess-1', limit: 50 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('filters out system and tool messages', async () => {
|
|
148
|
+
const { adapter, mock } = makeAdapter();
|
|
149
|
+
mock.request.mockResolvedValue({
|
|
150
|
+
messages: [
|
|
151
|
+
{ role: 'user', content: 'hello', timestamp: '2026-01-01T00:00:00Z' },
|
|
152
|
+
{ role: 'system', content: 'ignored' },
|
|
153
|
+
{ role: 'assistant', content: 'hi', timestamp: '2026-01-01T00:00:01Z' },
|
|
154
|
+
{ role: 'tool', content: 'ignored' },
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = await adapter.getHistory('sess-1');
|
|
159
|
+
|
|
160
|
+
expect(result).toHaveLength(2);
|
|
161
|
+
expect(result[0].role).toBe('user');
|
|
162
|
+
expect(result[1].role).toBe('assistant');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('concatenates text blocks when content is an array', async () => {
|
|
166
|
+
const { adapter, mock } = makeAdapter();
|
|
167
|
+
mock.request.mockResolvedValue({
|
|
168
|
+
messages: [
|
|
169
|
+
{
|
|
170
|
+
role: 'user',
|
|
171
|
+
content: [
|
|
172
|
+
{ type: 'text', text: 'foo' },
|
|
173
|
+
{ type: 'image', data: 'ignored' },
|
|
174
|
+
{ type: 'text', text: 'bar' },
|
|
175
|
+
],
|
|
176
|
+
timestamp: 0,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await adapter.getHistory('sess-1');
|
|
182
|
+
expect(result[0].content).toBe('foobar');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('uses string content directly', async () => {
|
|
186
|
+
const { adapter, mock } = makeAdapter();
|
|
187
|
+
mock.request.mockResolvedValue({
|
|
188
|
+
messages: [{ role: 'user', content: 'plain text', timestamp: '2026-01-01T00:00:00Z' }],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const result = await adapter.getHistory('sess-1');
|
|
192
|
+
expect(result[0].content).toBe('plain text');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('converts numeric timestamp to ISO string', async () => {
|
|
196
|
+
const { adapter, mock } = makeAdapter();
|
|
197
|
+
const ts = Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
198
|
+
mock.request.mockResolvedValue({
|
|
199
|
+
messages: [{ role: 'user', content: 'hi', timestamp: ts }],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await adapter.getHistory('sess-1');
|
|
203
|
+
expect(result[0].timestamp).toBe(new Date(ts).toISOString());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('returns [] when result is null', async () => {
|
|
207
|
+
const { adapter, mock } = makeAdapter();
|
|
208
|
+
mock.request.mockResolvedValue(null);
|
|
209
|
+
|
|
210
|
+
const result = await adapter.getHistory('sess-1');
|
|
211
|
+
expect(result).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('OpenclawGatewayAdapter – sendMessage()', () => {
|
|
216
|
+
it('sends chat.send request with correct payload', async () => {
|
|
217
|
+
const { adapter, mock } = makeAdapter();
|
|
218
|
+
mock.request.mockResolvedValue(undefined);
|
|
219
|
+
|
|
220
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hello', idempotencyKey: 'idem-1' });
|
|
221
|
+
|
|
222
|
+
expect(mock.request).toHaveBeenCalledWith('chat.send', {
|
|
223
|
+
sessionKey: 'sess-1',
|
|
224
|
+
message: 'hello',
|
|
225
|
+
deliver: false,
|
|
226
|
+
idempotencyKey: 'idem-1',
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('includes attachments in payload when provided', async () => {
|
|
231
|
+
const { adapter, mock } = makeAdapter();
|
|
232
|
+
mock.request.mockResolvedValue(undefined);
|
|
233
|
+
const attachments = [{ type: 'image' as const, mimeType: 'image/png', content: 'base64data' }];
|
|
234
|
+
|
|
235
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', attachments });
|
|
236
|
+
|
|
237
|
+
const [, payload] = mock.request.mock.calls[0] as [string, any];
|
|
238
|
+
expect(payload.attachments).toEqual(attachments);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('omits attachments field when not provided', async () => {
|
|
242
|
+
const { adapter, mock } = makeAdapter();
|
|
243
|
+
mock.request.mockResolvedValue(undefined);
|
|
244
|
+
|
|
245
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1' });
|
|
246
|
+
|
|
247
|
+
const [, payload] = mock.request.mock.calls[0] as [string, any];
|
|
248
|
+
expect(payload.attachments).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('calls onError and removes callback when request throws', async () => {
|
|
252
|
+
const { adapter, mock } = makeAdapter();
|
|
253
|
+
mock.request.mockRejectedValue(new Error('network fail'));
|
|
254
|
+
const onError = jest.fn();
|
|
255
|
+
|
|
256
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onError });
|
|
257
|
+
|
|
258
|
+
expect(onError).toHaveBeenCalledWith('network fail');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('calls onDelta when delta stream event arrives', async () => {
|
|
262
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
263
|
+
mock.request.mockResolvedValue(undefined);
|
|
264
|
+
const onDelta = jest.fn();
|
|
265
|
+
|
|
266
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDelta });
|
|
267
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'delta', message: 'partial text' });
|
|
268
|
+
|
|
269
|
+
expect(onDelta).toHaveBeenCalledWith('partial text');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('calls onDone with accumulated text on final event', async () => {
|
|
273
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
274
|
+
mock.request.mockResolvedValue(undefined);
|
|
275
|
+
const onDone = jest.fn();
|
|
276
|
+
|
|
277
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDone });
|
|
278
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'delta', message: 'full text' });
|
|
279
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'final' });
|
|
280
|
+
|
|
281
|
+
expect(onDone).toHaveBeenCalledWith('full text');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('calls onDone on aborted event with last accumulated text', async () => {
|
|
285
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
286
|
+
mock.request.mockResolvedValue(undefined);
|
|
287
|
+
const onDone = jest.fn();
|
|
288
|
+
|
|
289
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDone });
|
|
290
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'delta', message: 'partial' });
|
|
291
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'aborted' });
|
|
292
|
+
|
|
293
|
+
expect(onDone).toHaveBeenCalledWith('partial');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('calls onError when error stream event arrives with string error', async () => {
|
|
297
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
298
|
+
mock.request.mockResolvedValue(undefined);
|
|
299
|
+
const onError = jest.fn();
|
|
300
|
+
|
|
301
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onError });
|
|
302
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'error', error: 'stream error' });
|
|
303
|
+
|
|
304
|
+
expect(onError).toHaveBeenCalledWith('stream error');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('calls onError with error.message when error is an object', async () => {
|
|
308
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
309
|
+
mock.request.mockResolvedValue(undefined);
|
|
310
|
+
const onError = jest.fn();
|
|
311
|
+
|
|
312
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onError });
|
|
313
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'error', error: { message: 'obj error' } });
|
|
314
|
+
|
|
315
|
+
expect(onError).toHaveBeenCalledWith('obj error');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('ignores stream events with unknown runId', async () => {
|
|
319
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
320
|
+
mock.request.mockResolvedValue(undefined);
|
|
321
|
+
const onDelta = jest.fn();
|
|
322
|
+
|
|
323
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDelta });
|
|
324
|
+
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'unknown-id', state: 'delta', message: 'x' });
|
|
325
|
+
|
|
326
|
+
expect(onDelta).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('ignores events with missing sessionKey in payload', async () => {
|
|
330
|
+
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
331
|
+
mock.request.mockResolvedValue(undefined);
|
|
332
|
+
const onDelta = jest.fn();
|
|
333
|
+
|
|
334
|
+
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDelta });
|
|
335
|
+
simulateStreamEvent({ runId: 'k1', state: 'delta', message: 'x' } as any);
|
|
336
|
+
|
|
337
|
+
expect(onDelta).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('OpenclawGatewayAdapter – abort()', () => {
|
|
342
|
+
it('calls chat.abort with sessionKey and runId', async () => {
|
|
343
|
+
const { adapter, mock } = makeAdapter();
|
|
344
|
+
mock.request.mockResolvedValue(undefined);
|
|
345
|
+
|
|
346
|
+
await adapter.abort('sess-1', 'run-1');
|
|
347
|
+
|
|
348
|
+
expect(mock.request).toHaveBeenCalledWith('chat.abort', { sessionKey: 'sess-1', runId: 'run-1' });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('calls chat.abort without runId when not provided', async () => {
|
|
352
|
+
const { adapter, mock } = makeAdapter();
|
|
353
|
+
mock.request.mockResolvedValue(undefined);
|
|
354
|
+
|
|
355
|
+
await adapter.abort('sess-1');
|
|
356
|
+
|
|
357
|
+
expect(mock.request).toHaveBeenCalledWith('chat.abort', { sessionKey: 'sess-1', runId: undefined });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('does not throw when request rejects', async () => {
|
|
361
|
+
const { adapter, mock } = makeAdapter();
|
|
362
|
+
mock.request.mockRejectedValue(new Error('abort failed'));
|
|
363
|
+
|
|
364
|
+
await expect(adapter.abort('sess-1', 'run-1')).resolves.toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
});
|