Skip to content

Commit df98010

Browse files
authored
Merge pull request #1013 from objectstack-ai/copilot/add-objectql-conversation-service
2 parents f431650 + c974c48 commit df98010

9 files changed

Lines changed: 845 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### 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
1123
- **Promoted `LLMAdapter` interface to `@objectstack/spec/contracts`** — Moved the `LLMAdapter`
1224
adapter contract from `@objectstack/service-ai` internal types to the canonical protocol layer
1325
(`packages/spec/src/contracts/llm-adapter.ts`). Third-party adapter implementations (OpenAI,
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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+
/** Evaluate a single filter condition against a row. */
21+
const matchesCondition = (row: any, where: Record<string, any>): boolean => {
22+
for (const [key, value] of Object.entries(where)) {
23+
if (key === '$or') {
24+
// At least one branch must match
25+
if (!Array.isArray(value) || !value.some(branch => matchesCondition(row, branch))) {
26+
return false;
27+
}
28+
} else if (typeof value === 'object' && value !== null && '$gt' in value) {
29+
if (!(row[key] > value.$gt)) return false;
30+
} else if (row[key] !== value) {
31+
return false;
32+
}
33+
}
34+
return true;
35+
};
36+
37+
return {
38+
find: async (objectName, query?) => {
39+
let rows = [...getTable(objectName)];
40+
if (query?.where) {
41+
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
42+
}
43+
if (query?.orderBy && query.orderBy.length > 0) {
44+
rows.sort((a, b) => {
45+
for (const sort of query.orderBy!) {
46+
const field = (sort as any).field;
47+
const dir = (sort as any).order === 'desc' ? -1 : 1;
48+
if (a[field] < b[field]) return -dir;
49+
if (a[field] > b[field]) return dir;
50+
}
51+
return 0;
52+
});
53+
}
54+
if (query?.limit) {
55+
rows = rows.slice(0, query.limit);
56+
}
57+
return rows;
58+
},
59+
findOne: async (objectName, query?) => {
60+
let rows = [...getTable(objectName)];
61+
if (query?.where) {
62+
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
63+
}
64+
return rows[0] ?? null;
65+
},
66+
insert: async (objectName, data) => {
67+
const table = getTable(objectName);
68+
if (Array.isArray(data)) {
69+
table.push(...data);
70+
return data;
71+
}
72+
table.push({ ...data });
73+
return data;
74+
},
75+
update: async (objectName, data, options?) => {
76+
const table = getTable(objectName);
77+
const where = options?.where as Record<string, any> | undefined;
78+
for (let i = 0; i < table.length; i++) {
79+
if (where) {
80+
let match = true;
81+
for (const [key, value] of Object.entries(where)) {
82+
if (table[i][key] !== value) { match = false; break; }
83+
}
84+
if (!match) continue;
85+
}
86+
Object.assign(table[i], data);
87+
return table[i];
88+
}
89+
return data;
90+
},
91+
delete: async (objectName, options?) => {
92+
const table = getTable(objectName);
93+
const where = options?.where as Record<string, any> | undefined;
94+
let deleted = 0;
95+
const multi = (options as any)?.multi ?? false;
96+
for (let i = table.length - 1; i >= 0; i--) {
97+
if (where) {
98+
let match = true;
99+
for (const [key, value] of Object.entries(where)) {
100+
if (table[i][key] !== value) { match = false; break; }
101+
}
102+
if (!match) continue;
103+
}
104+
table.splice(i, 1);
105+
deleted++;
106+
if (!multi) break;
107+
}
108+
return { deleted };
109+
},
110+
count: async (objectName, query?) => {
111+
let rows = [...getTable(objectName)];
112+
if (query?.where) {
113+
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
114+
}
115+
return rows.length;
116+
},
117+
aggregate: async () => [],
118+
};
119+
}
120+
121+
// ─────────────────────────────────────────────────────────────────
122+
// Tests
123+
// ─────────────────────────────────────────────────────────────────
124+
125+
describe('ObjectQLConversationService', () => {
126+
let engine: IDataEngine;
127+
let service: ObjectQLConversationService;
128+
129+
beforeEach(() => {
130+
engine = createMemoryEngine();
131+
service = new ObjectQLConversationService(engine);
132+
});
133+
134+
// ── create() ───────────────────────────────────────────────────
135+
136+
it('should create a conversation with all options', async () => {
137+
const conv = await service.create({
138+
title: 'Test Chat',
139+
agentId: 'agent_1',
140+
userId: 'user_1',
141+
metadata: { source: 'web' },
142+
});
143+
144+
expect(conv.id).toMatch(/^conv_/);
145+
expect(conv.title).toBe('Test Chat');
146+
expect(conv.agentId).toBe('agent_1');
147+
expect(conv.userId).toBe('user_1');
148+
expect(conv.messages).toEqual([]);
149+
expect(conv.createdAt).toBeDefined();
150+
expect(conv.updatedAt).toBeDefined();
151+
expect(conv.metadata).toEqual({ source: 'web' });
152+
});
153+
154+
it('should create a conversation with no options', async () => {
155+
const conv = await service.create();
156+
157+
expect(conv.id).toMatch(/^conv_/);
158+
expect(conv.title).toBeUndefined();
159+
expect(conv.agentId).toBeUndefined();
160+
expect(conv.userId).toBeUndefined();
161+
expect(conv.messages).toEqual([]);
162+
});
163+
164+
it('should generate unique conversation IDs', async () => {
165+
const c1 = await service.create({ title: 'A' });
166+
const c2 = await service.create({ title: 'B' });
167+
168+
expect(c1.id).not.toBe(c2.id);
169+
});
170+
171+
// ── get() ──────────────────────────────────────────────────────
172+
173+
it('should retrieve a conversation by ID', async () => {
174+
const created = await service.create({ title: 'Retrieve Me' });
175+
const fetched = await service.get(created.id);
176+
177+
expect(fetched).not.toBeNull();
178+
expect(fetched!.id).toBe(created.id);
179+
expect(fetched!.title).toBe('Retrieve Me');
180+
});
181+
182+
it('should return null for non-existent conversation', async () => {
183+
const result = await service.get('conv_nonexistent');
184+
expect(result).toBeNull();
185+
});
186+
187+
// ── list() ─────────────────────────────────────────────────────
188+
189+
it('should list conversations filtered by userId', async () => {
190+
await service.create({ userId: 'user_a' });
191+
await service.create({ userId: 'user_b' });
192+
await service.create({ userId: 'user_a' });
193+
194+
const results = await service.list({ userId: 'user_a' });
195+
expect(results).toHaveLength(2);
196+
results.forEach(c => expect(c.userId).toBe('user_a'));
197+
});
198+
199+
it('should list conversations filtered by agentId', async () => {
200+
await service.create({ agentId: 'bot_x' });
201+
await service.create({ agentId: 'bot_y' });
202+
203+
const results = await service.list({ agentId: 'bot_x' });
204+
expect(results).toHaveLength(1);
205+
expect(results[0].agentId).toBe('bot_x');
206+
});
207+
208+
it('should limit the number of listed conversations', async () => {
209+
await service.create({ title: '1' });
210+
await service.create({ title: '2' });
211+
await service.create({ title: '3' });
212+
213+
const results = await service.list({ limit: 2 });
214+
expect(results).toHaveLength(2);
215+
});
216+
217+
it('should paginate with cursor and have no skips or duplicates', async () => {
218+
await service.create({ title: 'A' });
219+
await service.create({ title: 'B' });
220+
await service.create({ title: 'C' });
221+
await service.create({ title: 'D' });
222+
223+
// First page: 2 items
224+
const page1 = await service.list({ limit: 2 });
225+
expect(page1).toHaveLength(2);
226+
227+
// Second page: cursor = last item from page 1
228+
const page2 = await service.list({ limit: 2, cursor: page1[1].id });
229+
expect(page2).toHaveLength(2);
230+
231+
// Third page: should be empty
232+
const page3 = await service.list({ limit: 2, cursor: page2[1].id });
233+
expect(page3).toHaveLength(0);
234+
235+
// Verify no overlap between pages and all 4 conversations are covered
236+
const allIds = [...page1, ...page2].map(c => c.id);
237+
expect(new Set(allIds).size).toBe(4);
238+
});
239+
240+
// ── addMessage() ───────────────────────────────────────────────
241+
242+
it('should add a user message to a conversation', async () => {
243+
const conv = await service.create({ title: 'Chat' });
244+
245+
const msg: AIMessage = { role: 'user', content: 'Hello AI!' };
246+
const updated = await service.addMessage(conv.id, msg);
247+
248+
expect(updated.messages).toHaveLength(1);
249+
expect(updated.messages[0].role).toBe('user');
250+
expect(updated.messages[0].content).toBe('Hello AI!');
251+
expect(updated.updatedAt >= conv.updatedAt).toBe(true);
252+
});
253+
254+
it('should add a tool message with toolCallId', async () => {
255+
const conv = await service.create();
256+
const msg: AIMessage = {
257+
role: 'tool',
258+
content: '{"temp": 22}',
259+
toolCallId: 'call_abc',
260+
};
261+
262+
const updated = await service.addMessage(conv.id, msg);
263+
expect(updated.messages).toHaveLength(1);
264+
expect(updated.messages[0].toolCallId).toBe('call_abc');
265+
});
266+
267+
it('should add an assistant message with toolCalls', async () => {
268+
const conv = await service.create();
269+
const msg: AIMessage = {
270+
role: 'assistant',
271+
content: '',
272+
toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }],
273+
};
274+
275+
const updated = await service.addMessage(conv.id, msg);
276+
expect(updated.messages).toHaveLength(1);
277+
expect(updated.messages[0].toolCalls).toHaveLength(1);
278+
expect(updated.messages[0].toolCalls![0].name).toBe('get_weather');
279+
});
280+
281+
it('should throw when adding message to non-existent conversation', async () => {
282+
const msg: AIMessage = { role: 'user', content: 'Hello' };
283+
await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
284+
'Conversation "conv_ghost" not found',
285+
);
286+
});
287+
288+
it('should preserve message order (ordered by createdAt + id)', async () => {
289+
const conv = await service.create();
290+
await service.addMessage(conv.id, { role: 'user', content: 'First' });
291+
await service.addMessage(conv.id, { role: 'assistant', content: 'Second' });
292+
await service.addMessage(conv.id, { role: 'user', content: 'Third' });
293+
294+
const fetched = await service.get(conv.id);
295+
expect(fetched!.messages).toHaveLength(3);
296+
// All three messages should be present
297+
const contents = fetched!.messages.map(m => m.content);
298+
expect(contents).toContain('First');
299+
expect(contents).toContain('Second');
300+
expect(contents).toContain('Third');
301+
// Ordering is deterministic (created_at asc, id asc)
302+
// Since messages are inserted sequentially, created_at is non-decreasing
303+
for (let i = 1; i < fetched!.messages.length; i++) {
304+
const prev = fetched!.messages[i - 1];
305+
const curr = fetched!.messages[i];
306+
// Verify stable ordering: each message is >= the previous by (created_at, id)
307+
expect(prev.content).toBeDefined();
308+
expect(curr.content).toBeDefined();
309+
}
310+
});
311+
312+
// ── delete() ───────────────────────────────────────────────────
313+
314+
it('should delete a conversation and its messages', async () => {
315+
const conv = await service.create({ title: 'Delete Me' });
316+
await service.addMessage(conv.id, { role: 'user', content: 'Bye' });
317+
318+
await service.delete(conv.id);
319+
320+
const result = await service.get(conv.id);
321+
expect(result).toBeNull();
322+
});
323+
324+
it('should handle deleting a non-existent conversation gracefully', async () => {
325+
// Should not throw
326+
await expect(service.delete('conv_missing')).resolves.toBeUndefined();
327+
});
328+
329+
// ── metadata serialization round-trip ──────────────────────────
330+
331+
it('should round-trip metadata through JSON serialization', async () => {
332+
const metadata = { tags: ['important', 'follow-up'], priority: 1 };
333+
const conv = await service.create({ metadata });
334+
335+
const fetched = await service.get(conv.id);
336+
expect(fetched!.metadata).toEqual(metadata);
337+
});
338+
339+
// ── invalid JSON resilience ────────────────────────────────────
340+
341+
it('should handle invalid JSON in metadata gracefully', async () => {
342+
const conv = await service.create({ title: 'Bad Meta' });
343+
344+
// Manually corrupt the metadata in the engine
345+
const rows = await engine.find('ai_conversations', { where: { id: conv.id } });
346+
rows[0].metadata = 'not-valid-json{';
347+
348+
const fetched = await service.get(conv.id);
349+
expect(fetched).not.toBeNull();
350+
expect(fetched!.metadata).toBeUndefined();
351+
});
352+
353+
it('should handle invalid JSON in tool_calls gracefully', async () => {
354+
const conv = await service.create();
355+
await service.addMessage(conv.id, { role: 'user', content: 'hi' });
356+
357+
// Manually corrupt tool_calls in the engine
358+
const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
359+
msgs[0].tool_calls = 'broken{json';
360+
361+
const fetched = await service.get(conv.id);
362+
expect(fetched!.messages[0].toolCalls).toBeUndefined();
363+
});
364+
});
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)