Skip to content

Commit a0734d2

Browse files
Copilothotlong
andauthored
feat(contracts): expand AI service contract with streaming, tool calling, and conversation management
- Add AIToolDefinition, AIToolCall, AIToolResult types for tool calling protocol - Add AIMessageWithTools extending AIMessage with 'tool' role and tool call fields - Add AIRequestOptionsWithTools extending AIRequestOptions with tool definitions - Add AIStreamEvent type for streaming chat completions - Add streamChat optional method to IAIService interface - Add AIConversation type and IAIConversationService interface for conversation CRUD - Add comprehensive tests for all new types and interfaces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent b7a7bb1 commit a0734d2

2 files changed

Lines changed: 507 additions & 1 deletion

File tree

packages/spec/src/contracts/ai-service.test.ts

Lines changed: 329 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import { describe, it, expect } from 'vitest';
2-
import type { IAIService, AIMessage, AIResult } from './ai-service';
2+
import type {
3+
IAIService,
4+
AIMessage,
5+
AIResult,
6+
AIToolDefinition,
7+
AIToolCall,
8+
AIToolResult,
9+
AIMessageWithTools,
10+
AIRequestOptionsWithTools,
11+
AIStreamEvent,
12+
AIConversation,
13+
IAIConversationService,
14+
} from './ai-service';
315

416
describe('AI Service Contract', () => {
517
it('should allow a minimal IAIService implementation with required methods', () => {
@@ -91,4 +103,320 @@ describe('AI Service Contract', () => {
91103
expect(models).toHaveLength(3);
92104
expect(models).toContain('gpt-4');
93105
});
106+
107+
// -----------------------------------------------------------------------
108+
// Tool Calling Types
109+
// -----------------------------------------------------------------------
110+
111+
describe('Tool Calling Types', () => {
112+
it('should construct valid AIToolDefinition values', () => {
113+
const tool: AIToolDefinition = {
114+
name: 'get_weather',
115+
description: 'Get current weather for a location',
116+
parameters: {
117+
type: 'object',
118+
properties: { location: { type: 'string' } },
119+
required: ['location'],
120+
},
121+
};
122+
123+
expect(tool.name).toBe('get_weather');
124+
expect(tool.description).toBe('Get current weather for a location');
125+
expect(tool.parameters).toBeDefined();
126+
});
127+
128+
it('should construct valid AIToolCall values', () => {
129+
const call: AIToolCall = {
130+
id: 'call_abc123',
131+
name: 'get_weather',
132+
arguments: JSON.stringify({ location: 'London' }),
133+
};
134+
135+
expect(call.id).toBe('call_abc123');
136+
expect(JSON.parse(call.arguments)).toEqual({ location: 'London' });
137+
});
138+
139+
it('should construct valid AIToolResult values', () => {
140+
const result: AIToolResult = {
141+
toolCallId: 'call_abc123',
142+
content: '{"temp": 18, "unit": "celsius"}',
143+
};
144+
145+
expect(result.toolCallId).toBe('call_abc123');
146+
expect(result.isError).toBeUndefined();
147+
148+
const errorResult: AIToolResult = {
149+
toolCallId: 'call_xyz',
150+
content: 'Tool not found',
151+
isError: true,
152+
};
153+
154+
expect(errorResult.isError).toBe(true);
155+
});
156+
157+
it('should support AIMessageWithTools for tool conversations', () => {
158+
const assistantMsg: AIMessageWithTools = {
159+
role: 'assistant',
160+
content: '',
161+
toolCalls: [
162+
{ id: 'call_1', name: 'get_weather', arguments: '{"location":"Paris"}' },
163+
],
164+
};
165+
166+
expect(assistantMsg.toolCalls).toHaveLength(1);
167+
expect(assistantMsg.toolCalls![0].name).toBe('get_weather');
168+
169+
const toolMsg: AIMessageWithTools = {
170+
role: 'tool',
171+
content: '{"temp": 22}',
172+
toolCallId: 'call_1',
173+
};
174+
175+
expect(toolMsg.role).toBe('tool');
176+
expect(toolMsg.toolCallId).toBe('call_1');
177+
});
178+
179+
it('should support AIRequestOptionsWithTools', () => {
180+
const options: AIRequestOptionsWithTools = {
181+
model: 'gpt-4',
182+
temperature: 0.7,
183+
tools: [
184+
{
185+
name: 'search',
186+
description: 'Search the web',
187+
parameters: { type: 'object', properties: {} },
188+
},
189+
],
190+
toolChoice: 'auto',
191+
};
192+
193+
expect(options.tools).toHaveLength(1);
194+
expect(options.toolChoice).toBe('auto');
195+
});
196+
});
197+
198+
// -----------------------------------------------------------------------
199+
// Streaming – streamChat
200+
// -----------------------------------------------------------------------
201+
202+
describe('streamChat', () => {
203+
it('should allow IAIService implementation with streamChat', () => {
204+
const service: IAIService = {
205+
chat: async () => ({ content: '' }),
206+
complete: async () => ({ content: '' }),
207+
async *streamChat(_messages, _options?) {
208+
yield { type: 'text-delta', textDelta: 'Hello' } satisfies AIStreamEvent;
209+
yield { type: 'finish', result: { content: 'Hello' } } satisfies AIStreamEvent;
210+
},
211+
};
212+
213+
expect(service.streamChat).toBeDefined();
214+
});
215+
216+
it('should stream text-delta events', async () => {
217+
const service: IAIService = {
218+
chat: async () => ({ content: '' }),
219+
complete: async () => ({ content: '' }),
220+
async *streamChat() {
221+
yield { type: 'text-delta' as const, textDelta: 'Hello' };
222+
yield { type: 'text-delta' as const, textDelta: ' world' };
223+
yield { type: 'finish' as const, result: { content: 'Hello world' } };
224+
},
225+
};
226+
227+
const events: AIStreamEvent[] = [];
228+
for await (const event of service.streamChat!([], {})) {
229+
events.push(event);
230+
}
231+
232+
expect(events).toHaveLength(3);
233+
expect(events[0].type).toBe('text-delta');
234+
expect(events[0].textDelta).toBe('Hello');
235+
expect(events[2].type).toBe('finish');
236+
expect(events[2].result?.content).toBe('Hello world');
237+
});
238+
239+
it('should stream tool-call events', async () => {
240+
const service: IAIService = {
241+
chat: async () => ({ content: '' }),
242+
complete: async () => ({ content: '' }),
243+
async *streamChat() {
244+
yield {
245+
type: 'tool-call-delta' as const,
246+
toolCall: { id: 'call_1', name: 'get_weather' },
247+
};
248+
yield {
249+
type: 'tool-call' as const,
250+
toolCall: { id: 'call_1', name: 'get_weather', arguments: '{"location":"NYC"}' },
251+
};
252+
yield { type: 'finish' as const, result: { content: '' } };
253+
},
254+
};
255+
256+
const events: AIStreamEvent[] = [];
257+
for await (const event of service.streamChat!([], {})) {
258+
events.push(event);
259+
}
260+
261+
expect(events[0].type).toBe('tool-call-delta');
262+
expect(events[1].toolCall?.arguments).toBe('{"location":"NYC"}');
263+
});
264+
265+
it('should stream error events', async () => {
266+
const service: IAIService = {
267+
chat: async () => ({ content: '' }),
268+
complete: async () => ({ content: '' }),
269+
async *streamChat() {
270+
yield { type: 'error' as const, error: 'Rate limit exceeded' };
271+
},
272+
};
273+
274+
const events: AIStreamEvent[] = [];
275+
for await (const event of service.streamChat!([], {})) {
276+
events.push(event);
277+
}
278+
279+
expect(events[0].type).toBe('error');
280+
expect(events[0].error).toBe('Rate limit exceeded');
281+
});
282+
});
283+
284+
// -----------------------------------------------------------------------
285+
// IAIConversationService
286+
// -----------------------------------------------------------------------
287+
288+
describe('IAIConversationService', () => {
289+
function createMockConversationService(): IAIConversationService {
290+
const store = new Map<string, AIConversation>();
291+
292+
return {
293+
async create(options = {}) {
294+
const now = new Date().toISOString();
295+
const conv: AIConversation = {
296+
id: `conv_${store.size + 1}`,
297+
title: options.title,
298+
agentId: options.agentId,
299+
userId: options.userId,
300+
messages: [],
301+
createdAt: now,
302+
updatedAt: now,
303+
metadata: options.metadata,
304+
};
305+
store.set(conv.id, conv);
306+
return conv;
307+
},
308+
309+
async get(conversationId) {
310+
return store.get(conversationId) ?? null;
311+
},
312+
313+
async list(options = {}) {
314+
let results = Array.from(store.values());
315+
if (options.userId) {
316+
results = results.filter((c) => c.userId === options.userId);
317+
}
318+
if (options.agentId) {
319+
results = results.filter((c) => c.agentId === options.agentId);
320+
}
321+
if (options.limit) {
322+
results = results.slice(0, options.limit);
323+
}
324+
return results;
325+
},
326+
327+
async addMessage(conversationId, message) {
328+
const conv = store.get(conversationId);
329+
if (!conv) throw new Error('Conversation not found');
330+
conv.messages.push(message);
331+
conv.updatedAt = new Date().toISOString();
332+
return conv;
333+
},
334+
335+
async delete(conversationId) {
336+
store.delete(conversationId);
337+
},
338+
};
339+
}
340+
341+
it('should create a conversation', async () => {
342+
const svc = createMockConversationService();
343+
const conv = await svc.create({ title: 'Test Chat', userId: 'user_1' });
344+
345+
expect(conv.id).toBeDefined();
346+
expect(conv.title).toBe('Test Chat');
347+
expect(conv.userId).toBe('user_1');
348+
expect(conv.messages).toHaveLength(0);
349+
expect(conv.createdAt).toBeDefined();
350+
});
351+
352+
it('should get a conversation by ID', async () => {
353+
const svc = createMockConversationService();
354+
const created = await svc.create({ title: 'Lookup Test' });
355+
356+
const found = await svc.get(created.id);
357+
expect(found).not.toBeNull();
358+
expect(found!.id).toBe(created.id);
359+
360+
const missing = await svc.get('nonexistent');
361+
expect(missing).toBeNull();
362+
});
363+
364+
it('should list conversations with filters', async () => {
365+
const svc = createMockConversationService();
366+
await svc.create({ userId: 'user_a', agentId: 'agent_1' });
367+
await svc.create({ userId: 'user_b', agentId: 'agent_1' });
368+
await svc.create({ userId: 'user_a', agentId: 'agent_2' });
369+
370+
const all = await svc.list();
371+
expect(all).toHaveLength(3);
372+
373+
const byUser = await svc.list({ userId: 'user_a' });
374+
expect(byUser).toHaveLength(2);
375+
376+
const byAgent = await svc.list({ agentId: 'agent_1' });
377+
expect(byAgent).toHaveLength(2);
378+
379+
const limited = await svc.list({ limit: 1 });
380+
expect(limited).toHaveLength(1);
381+
});
382+
383+
it('should add messages to a conversation', async () => {
384+
const svc = createMockConversationService();
385+
const conv = await svc.create({ title: 'Message Test' });
386+
387+
const updated = await svc.addMessage(conv.id, {
388+
role: 'user',
389+
content: 'Hello!',
390+
});
391+
392+
expect(updated.messages).toHaveLength(1);
393+
expect(updated.messages[0].content).toBe('Hello!');
394+
395+
const updated2 = await svc.addMessage(conv.id, {
396+
role: 'assistant',
397+
content: 'Hi there!',
398+
});
399+
400+
expect(updated2.messages).toHaveLength(2);
401+
});
402+
403+
it('should delete a conversation', async () => {
404+
const svc = createMockConversationService();
405+
const conv = await svc.create({ title: 'Delete Me' });
406+
407+
await svc.delete(conv.id);
408+
const result = await svc.get(conv.id);
409+
expect(result).toBeNull();
410+
});
411+
412+
it('should support metadata on conversations', async () => {
413+
const svc = createMockConversationService();
414+
const conv = await svc.create({
415+
title: 'With Meta',
416+
metadata: { source: 'web', tags: ['support'] },
417+
});
418+
419+
expect(conv.metadata).toEqual({ source: 'web', tags: ['support'] });
420+
});
421+
});
94422
});

0 commit comments

Comments
 (0)