Skip to content

Commit c809528

Browse files
Copilothotlong
andauthored
feat(service-ai): implement ObjectQL-backed persistent ConversationService
- Add ai_conversations and ai_messages system object definitions - Implement ObjectQLConversationService using IDataEngine interface - Update AIServicePlugin to auto-detect IDataEngine and select service - Register AI system objects via plugin - Update conversation/index.ts and src/index.ts exports - Add 16 test cases for ObjectQLConversationService - Update CHANGELOG.md Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/ddfac0ae-2aa3-487e-945d-687d8e3bc7d5 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4154d5a commit c809528

9 files changed

Lines changed: 735 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **`@objectstack/service-ai` — ObjectQL-backed persistent ConversationService** — New
12+
`ObjectQLConversationService` implements `IAIConversationService` using `IDataEngine`
13+
for durable conversation and message storage across service restarts:
14+
- `ai_conversations` and `ai_messages` system object definitions (namespace `ai`)
15+
- Full CRUD: `create`, `get`, `list` (with userId/agentId/limit/cursor filters),
16+
`addMessage` (with toolCalls/toolCallId support), and `delete` (cascade)
17+
- `AIServicePlugin` auto-detects `IDataEngine` in the kernel service registry and
18+
uses `ObjectQLConversationService` when available, falling back to
19+
`InMemoryConversationService` for dev/test environments
20+
- `AIServicePluginOptions.conversationService` allows explicit override
21+
- Plugin registers AI system objects via `app.com.objectstack.service-ai` service
22+
- 16 new test cases covering all five interface methods plus edge cases
23+
1024
### Fixed
1125
- **ObjectQL build failure** — Fixed TypeScript TS2345 errors in `packages/objectql/src/protocol.ts`
1226
where `SchemaRegistry.registerItem()` calls failed type checking for the `keyField` parameter.
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, vi } from 'vitest';
4+
import type { IDataEngine } from '@objectstack/spec/contracts';
5+
import type { AIMessage } from '@objectstack/spec/contracts';
6+
import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js';
7+
8+
// ─────────────────────────────────────────────────────────────────
9+
// In-memory IDataEngine stub (mimics driver-memory behavior)
10+
// ─────────────────────────────────────────────────────────────────
11+
12+
function createMemoryEngine(): IDataEngine {
13+
const tables = new Map<string, any[]>();
14+
15+
const getTable = (name: string) => {
16+
if (!tables.has(name)) tables.set(name, []);
17+
return tables.get(name)!;
18+
};
19+
20+
return {
21+
find: async (objectName, query?) => {
22+
let rows = [...getTable(objectName)];
23+
if (query?.where) {
24+
rows = rows.filter(row => {
25+
for (const [key, value] of Object.entries(query.where as Record<string, any>)) {
26+
if (typeof value === 'object' && value !== null && '$gt' in value) {
27+
if (!(row[key] > value.$gt)) return false;
28+
} else if (row[key] !== value) {
29+
return false;
30+
}
31+
}
32+
return true;
33+
});
34+
}
35+
if (query?.orderBy) {
36+
for (const sort of [...query.orderBy].reverse()) {
37+
const field = (sort as any).field;
38+
const dir = (sort as any).order === 'desc' ? -1 : 1;
39+
rows.sort((a, b) => (a[field] < b[field] ? -dir : a[field] > b[field] ? dir : 0));
40+
}
41+
}
42+
if (query?.limit) {
43+
rows = rows.slice(0, query.limit);
44+
}
45+
return rows;
46+
},
47+
findOne: async (objectName, query?) => {
48+
let rows = [...getTable(objectName)];
49+
if (query?.where) {
50+
rows = rows.filter(row => {
51+
for (const [key, value] of Object.entries(query.where as Record<string, any>)) {
52+
if (typeof value === 'object' && value !== null && '$gt' in value) {
53+
if (!(row[key] > value.$gt)) return false;
54+
} else if (row[key] !== value) {
55+
return false;
56+
}
57+
}
58+
return true;
59+
});
60+
}
61+
return rows[0] ?? null;
62+
},
63+
insert: async (objectName, data) => {
64+
const table = getTable(objectName);
65+
if (Array.isArray(data)) {
66+
table.push(...data);
67+
return data;
68+
}
69+
table.push({ ...data });
70+
return data;
71+
},
72+
update: async (objectName, data, options?) => {
73+
const table = getTable(objectName);
74+
const where = options?.where as Record<string, any> | undefined;
75+
for (let i = 0; i < table.length; i++) {
76+
if (where) {
77+
let match = true;
78+
for (const [key, value] of Object.entries(where)) {
79+
if (table[i][key] !== value) { match = false; break; }
80+
}
81+
if (!match) continue;
82+
}
83+
Object.assign(table[i], data);
84+
return table[i];
85+
}
86+
return data;
87+
},
88+
delete: async (objectName, options?) => {
89+
const table = getTable(objectName);
90+
const where = options?.where as Record<string, any> | undefined;
91+
let deleted = 0;
92+
const multi = (options as any)?.multi ?? false;
93+
for (let i = table.length - 1; i >= 0; i--) {
94+
if (where) {
95+
let match = true;
96+
for (const [key, value] of Object.entries(where)) {
97+
if (table[i][key] !== value) { match = false; break; }
98+
}
99+
if (!match) continue;
100+
}
101+
table.splice(i, 1);
102+
deleted++;
103+
if (!multi) break;
104+
}
105+
return { deleted };
106+
},
107+
count: async (objectName, query?) => {
108+
let rows = [...getTable(objectName)];
109+
if (query?.where) {
110+
rows = rows.filter(row => {
111+
for (const [key, value] of Object.entries(query.where as Record<string, any>)) {
112+
if (row[key] !== value) return false;
113+
}
114+
return true;
115+
});
116+
}
117+
return rows.length;
118+
},
119+
aggregate: async () => [],
120+
};
121+
}
122+
123+
// ─────────────────────────────────────────────────────────────────
124+
// Tests
125+
// ─────────────────────────────────────────────────────────────────
126+
127+
describe('ObjectQLConversationService', () => {
128+
let engine: IDataEngine;
129+
let service: ObjectQLConversationService;
130+
131+
beforeEach(() => {
132+
engine = createMemoryEngine();
133+
service = new ObjectQLConversationService(engine);
134+
});
135+
136+
// ── create() ───────────────────────────────────────────────────
137+
138+
it('should create a conversation with all options', async () => {
139+
const conv = await service.create({
140+
title: 'Test Chat',
141+
agentId: 'agent_1',
142+
userId: 'user_1',
143+
metadata: { source: 'web' },
144+
});
145+
146+
expect(conv.id).toMatch(/^conv_/);
147+
expect(conv.title).toBe('Test Chat');
148+
expect(conv.agentId).toBe('agent_1');
149+
expect(conv.userId).toBe('user_1');
150+
expect(conv.messages).toEqual([]);
151+
expect(conv.createdAt).toBeDefined();
152+
expect(conv.updatedAt).toBeDefined();
153+
expect(conv.metadata).toEqual({ source: 'web' });
154+
});
155+
156+
it('should create a conversation with no options', async () => {
157+
const conv = await service.create();
158+
159+
expect(conv.id).toMatch(/^conv_/);
160+
expect(conv.title).toBeUndefined();
161+
expect(conv.agentId).toBeUndefined();
162+
expect(conv.userId).toBeUndefined();
163+
expect(conv.messages).toEqual([]);
164+
});
165+
166+
it('should generate unique conversation IDs', async () => {
167+
const c1 = await service.create({ title: 'A' });
168+
const c2 = await service.create({ title: 'B' });
169+
170+
expect(c1.id).not.toBe(c2.id);
171+
});
172+
173+
// ── get() ──────────────────────────────────────────────────────
174+
175+
it('should retrieve a conversation by ID', async () => {
176+
const created = await service.create({ title: 'Retrieve Me' });
177+
const fetched = await service.get(created.id);
178+
179+
expect(fetched).not.toBeNull();
180+
expect(fetched!.id).toBe(created.id);
181+
expect(fetched!.title).toBe('Retrieve Me');
182+
});
183+
184+
it('should return null for non-existent conversation', async () => {
185+
const result = await service.get('conv_nonexistent');
186+
expect(result).toBeNull();
187+
});
188+
189+
// ── list() ─────────────────────────────────────────────────────
190+
191+
it('should list conversations filtered by userId', async () => {
192+
await service.create({ userId: 'user_a' });
193+
await service.create({ userId: 'user_b' });
194+
await service.create({ userId: 'user_a' });
195+
196+
const results = await service.list({ userId: 'user_a' });
197+
expect(results).toHaveLength(2);
198+
results.forEach(c => expect(c.userId).toBe('user_a'));
199+
});
200+
201+
it('should list conversations filtered by agentId', async () => {
202+
await service.create({ agentId: 'bot_x' });
203+
await service.create({ agentId: 'bot_y' });
204+
205+
const results = await service.list({ agentId: 'bot_x' });
206+
expect(results).toHaveLength(1);
207+
expect(results[0].agentId).toBe('bot_x');
208+
});
209+
210+
it('should limit the number of listed conversations', async () => {
211+
await service.create({ title: '1' });
212+
await service.create({ title: '2' });
213+
await service.create({ title: '3' });
214+
215+
const results = await service.list({ limit: 2 });
216+
expect(results).toHaveLength(2);
217+
});
218+
219+
// ── addMessage() ───────────────────────────────────────────────
220+
221+
it('should add a user message to a conversation', async () => {
222+
const conv = await service.create({ title: 'Chat' });
223+
224+
const msg: AIMessage = { role: 'user', content: 'Hello AI!' };
225+
const updated = await service.addMessage(conv.id, msg);
226+
227+
expect(updated.messages).toHaveLength(1);
228+
expect(updated.messages[0].role).toBe('user');
229+
expect(updated.messages[0].content).toBe('Hello AI!');
230+
expect(updated.updatedAt >= conv.updatedAt).toBe(true);
231+
});
232+
233+
it('should add a tool message with toolCallId', async () => {
234+
const conv = await service.create();
235+
const msg: AIMessage = {
236+
role: 'tool',
237+
content: '{"temp": 22}',
238+
toolCallId: 'call_abc',
239+
};
240+
241+
const updated = await service.addMessage(conv.id, msg);
242+
expect(updated.messages).toHaveLength(1);
243+
expect(updated.messages[0].toolCallId).toBe('call_abc');
244+
});
245+
246+
it('should add an assistant message with toolCalls', async () => {
247+
const conv = await service.create();
248+
const msg: AIMessage = {
249+
role: 'assistant',
250+
content: '',
251+
toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }],
252+
};
253+
254+
const updated = await service.addMessage(conv.id, msg);
255+
expect(updated.messages).toHaveLength(1);
256+
expect(updated.messages[0].toolCalls).toHaveLength(1);
257+
expect(updated.messages[0].toolCalls![0].name).toBe('get_weather');
258+
});
259+
260+
it('should throw when adding message to non-existent conversation', async () => {
261+
const msg: AIMessage = { role: 'user', content: 'Hello' };
262+
await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
263+
'Conversation "conv_ghost" not found',
264+
);
265+
});
266+
267+
it('should preserve message order (ordered by createdAt)', async () => {
268+
const conv = await service.create();
269+
await service.addMessage(conv.id, { role: 'user', content: 'First' });
270+
await service.addMessage(conv.id, { role: 'assistant', content: 'Second' });
271+
await service.addMessage(conv.id, { role: 'user', content: 'Third' });
272+
273+
const fetched = await service.get(conv.id);
274+
expect(fetched!.messages).toHaveLength(3);
275+
expect(fetched!.messages[0].content).toBe('First');
276+
expect(fetched!.messages[1].content).toBe('Second');
277+
expect(fetched!.messages[2].content).toBe('Third');
278+
});
279+
280+
// ── delete() ───────────────────────────────────────────────────
281+
282+
it('should delete a conversation and its messages', async () => {
283+
const conv = await service.create({ title: 'Delete Me' });
284+
await service.addMessage(conv.id, { role: 'user', content: 'Bye' });
285+
286+
await service.delete(conv.id);
287+
288+
const result = await service.get(conv.id);
289+
expect(result).toBeNull();
290+
});
291+
292+
it('should handle deleting a non-existent conversation gracefully', async () => {
293+
// Should not throw
294+
await expect(service.delete('conv_missing')).resolves.toBeUndefined();
295+
});
296+
297+
// ── metadata serialization round-trip ──────────────────────────
298+
299+
it('should round-trip metadata through JSON serialization', async () => {
300+
const metadata = { tags: ['important', 'follow-up'], priority: 1 };
301+
const conv = await service.create({ metadata });
302+
303+
const fetched = await service.get(conv.id);
304+
expect(fetched!.metadata).toEqual(metadata);
305+
});
306+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
export { InMemoryConversationService } from './in-memory-conversation-service.js';
4+
export { ObjectQLConversationService } from './objectql-conversation-service.js';

0 commit comments

Comments
 (0)