Skip to content

Commit 2436e95

Browse files
Copilothotlong
andauthored
fix: address all review feedback — unify AIMessage, widen tool support, use contracts, add input validation
- Unify AIMessage type: 'tool' role + toolCalls/toolCallId now on base type - AIRequestOptions now includes tools/toolChoice for non-streaming tool calling - AIMessageWithTools/AIRequestOptionsWithTools kept as deprecated aliases - AIServiceConfig.conversationService typed as IAIConversationService - buildAIRoutes depends on IAIService + IAIConversationService contracts - Routes validate message role (system/user/assistant/tool) and content - GET /conversations parses and validates limit from query string - POST /conversations/:id/messages validates role before persisting - 60 service tests + 22 contract tests = 82 total passing Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/be512058-7717-4f5d-b4ba-9861ca48a4e9 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 1680950 commit 2436e95

8 files changed

Lines changed: 251 additions & 69 deletions

File tree

packages/services/service-ai/src/__tests__/ai-service.test.ts

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ describe('AI Routes', () => {
365365
});
366366

367367
it('should build all expected routes', () => {
368-
const routes = buildAIRoutes(service, silentLogger);
368+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
369369
expect(routes.length).toBe(8);
370370

371371
const paths = routes.map(r => `${r.method} ${r.path}`);
@@ -380,7 +380,7 @@ describe('AI Routes', () => {
380380
});
381381

382382
it('POST /api/v1/ai/chat should return chat result', async () => {
383-
const routes = buildAIRoutes(service, silentLogger);
383+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
384384
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
385385

386386
const response = await chatRoute.handler({
@@ -392,15 +392,15 @@ describe('AI Routes', () => {
392392
});
393393

394394
it('POST /api/v1/ai/chat should return 400 without messages', async () => {
395-
const routes = buildAIRoutes(service, silentLogger);
395+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
396396
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
397397

398398
const response = await chatRoute.handler({ body: {} });
399399
expect(response.status).toBe(400);
400400
});
401401

402402
it('POST /api/v1/ai/chat/stream should return streaming response', async () => {
403-
const routes = buildAIRoutes(service, silentLogger);
403+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
404404
const streamRoute = routes.find(r => r.path === '/api/v1/ai/chat/stream')!;
405405

406406
const response = await streamRoute.handler({
@@ -420,7 +420,7 @@ describe('AI Routes', () => {
420420
});
421421

422422
it('POST /api/v1/ai/complete should return completion result', async () => {
423-
const routes = buildAIRoutes(service, silentLogger);
423+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
424424
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
425425

426426
const response = await completeRoute.handler({
@@ -432,15 +432,15 @@ describe('AI Routes', () => {
432432
});
433433

434434
it('POST /api/v1/ai/complete should return 400 without prompt', async () => {
435-
const routes = buildAIRoutes(service, silentLogger);
435+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
436436
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
437437

438438
const response = await completeRoute.handler({ body: {} });
439439
expect(response.status).toBe(400);
440440
});
441441

442442
it('GET /api/v1/ai/models should return model list', async () => {
443-
const routes = buildAIRoutes(service, silentLogger);
443+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
444444
const modelsRoute = routes.find(r => r.path === '/api/v1/ai/models')!;
445445

446446
const response = await modelsRoute.handler({});
@@ -449,7 +449,7 @@ describe('AI Routes', () => {
449449
});
450450

451451
it('POST /api/v1/ai/conversations should create conversation', async () => {
452-
const routes = buildAIRoutes(service, silentLogger);
452+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
453453
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
454454

455455
const response = await createRoute.handler({
@@ -461,7 +461,7 @@ describe('AI Routes', () => {
461461
});
462462

463463
it('GET /api/v1/ai/conversations should list conversations', async () => {
464-
const routes = buildAIRoutes(service, silentLogger);
464+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
465465
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
466466
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
467467

@@ -474,7 +474,7 @@ describe('AI Routes', () => {
474474
});
475475

476476
it('POST /api/v1/ai/conversations/:id/messages should add message', async () => {
477-
const routes = buildAIRoutes(service, silentLogger);
477+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
478478
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
479479
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
480480

@@ -491,7 +491,7 @@ describe('AI Routes', () => {
491491
});
492492

493493
it('POST /api/v1/ai/conversations/:id/messages should return 404 for unknown conversation', async () => {
494-
const routes = buildAIRoutes(service, silentLogger);
494+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
495495
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
496496

497497
const response = await addMsgRoute.handler({
@@ -503,7 +503,7 @@ describe('AI Routes', () => {
503503
});
504504

505505
it('DELETE /api/v1/ai/conversations/:id should delete conversation', async () => {
506-
const routes = buildAIRoutes(service, silentLogger);
506+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
507507
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
508508
const deleteRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id')!;
509509

@@ -513,6 +513,115 @@ describe('AI Routes', () => {
513513
const response = await deleteRoute.handler({ params: { id: convId } });
514514
expect(response.status).toBe(204);
515515
});
516+
517+
// ── Message validation ───────────────────────────────────────
518+
519+
it('POST /api/v1/ai/chat should return 400 for messages with invalid role', async () => {
520+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
521+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
522+
523+
const response = await chatRoute.handler({
524+
body: { messages: [{ role: 'invalid', content: 'Hi' }] },
525+
});
526+
527+
expect(response.status).toBe(400);
528+
expect((response.body as any).error).toContain('message.role');
529+
});
530+
531+
it('POST /api/v1/ai/chat should return 400 for messages with non-string content', async () => {
532+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
533+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
534+
535+
const response = await chatRoute.handler({
536+
body: { messages: [{ role: 'user', content: 123 }] },
537+
});
538+
539+
expect(response.status).toBe(400);
540+
expect((response.body as any).error).toContain('content');
541+
});
542+
543+
it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
544+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
545+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
546+
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
547+
548+
const created = await createRoute.handler({ body: {} });
549+
const convId = (created.body as any).id;
550+
551+
const response = await addMsgRoute.handler({
552+
params: { id: convId },
553+
body: { role: 'invalid_role', content: 'Hi' },
554+
});
555+
556+
expect(response.status).toBe(400);
557+
expect((response.body as any).error).toContain('message.role');
558+
});
559+
560+
it('POST /api/v1/ai/conversations/:id/messages should return 400 for missing content', async () => {
561+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
562+
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
563+
564+
const response = await addMsgRoute.handler({
565+
params: { id: 'conv_1' },
566+
body: { role: 'user' },
567+
});
568+
569+
expect(response.status).toBe(400);
570+
expect((response.body as any).error).toContain('content');
571+
});
572+
573+
// ── Limit parsing ───────────────────────────────────────────
574+
575+
it('GET /api/v1/ai/conversations should parse limit from query string', async () => {
576+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
577+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
578+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
579+
580+
await createRoute.handler({ body: { title: 'C1' } });
581+
await createRoute.handler({ body: { title: 'C2' } });
582+
await createRoute.handler({ body: { title: 'C3' } });
583+
584+
const response = await listRoute.handler({ query: { limit: '2' } });
585+
expect(response.status).toBe(200);
586+
expect((response.body as any).conversations).toHaveLength(2);
587+
});
588+
589+
it('GET /api/v1/ai/conversations should return 400 for invalid limit', async () => {
590+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
591+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
592+
593+
const response = await listRoute.handler({ query: { limit: 'abc' } });
594+
expect(response.status).toBe(400);
595+
expect((response.body as any).error).toContain('limit');
596+
});
597+
598+
it('GET /api/v1/ai/conversations should return 400 for negative limit', async () => {
599+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
600+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
601+
602+
const response = await listRoute.handler({ query: { limit: '-1' } });
603+
expect(response.status).toBe(400);
604+
expect((response.body as any).error).toContain('limit');
605+
});
606+
607+
// ── Tool message in chat ────────────────────────────────────
608+
609+
it('POST /api/v1/ai/chat should accept tool role messages', async () => {
610+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
611+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
612+
613+
const response = await chatRoute.handler({
614+
body: {
615+
messages: [
616+
{ role: 'user', content: 'What is the weather?' },
617+
{ role: 'assistant', content: '' },
618+
{ role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
619+
],
620+
},
621+
});
622+
623+
expect(response.status).toBe(200);
624+
});
516625
});
517626

518627
// ─────────────────────────────────────────────────────────────────

packages/services/service-ai/src/adapters/memory-adapter.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import type {
44
AIMessage,
55
AIRequestOptions,
6-
AIRequestOptionsWithTools,
76
AIResult,
87
AIStreamEvent,
98
} from '@objectstack/spec/contracts';
@@ -41,7 +40,7 @@ export class MemoryLLMAdapter implements LLMAdapter {
4140

4241
async *streamChat(
4342
messages: AIMessage[],
44-
_options?: AIRequestOptionsWithTools,
43+
_options?: AIRequestOptions,
4544
): AsyncIterable<AIStreamEvent> {
4645
const result = await this.chat(messages);
4746
// Emit word-by-word deltas for realistic streaming simulation

packages/services/service-ai/src/adapters/types.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import type {
44
AIMessage,
55
AIRequestOptions,
6-
AIRequestOptionsWithTools,
76
AIResult,
87
AIStreamEvent,
98
} from '@objectstack/spec/contracts';
@@ -24,7 +23,7 @@ export interface LLMAdapter {
2423
/**
2524
* Generate a chat completion.
2625
* @param messages - Conversation messages
27-
* @param options - Request configuration
26+
* @param options - Request configuration (includes tool definitions)
2827
*/
2928
chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult>;
3029

@@ -39,7 +38,7 @@ export interface LLMAdapter {
3938
* Stream a chat completion as an async iterable of events.
4039
* Implementations that do not support streaming may omit this method.
4140
*/
42-
streamChat?(messages: AIMessage[], options?: AIRequestOptionsWithTools): AsyncIterable<AIStreamEvent>;
41+
streamChat?(messages: AIMessage[], options?: AIRequestOptions): AsyncIterable<AIStreamEvent>;
4342

4443
/**
4544
* Generate embedding vectors.

packages/services/service-ai/src/ai-service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import type {
44
AIMessage,
55
AIRequestOptions,
6-
AIRequestOptionsWithTools,
76
AIResult,
87
AIStreamEvent,
98
IAIService,
9+
IAIConversationService,
1010
} from '@objectstack/spec/contracts';
1111
import type { Logger } from '@objectstack/spec/contracts';
1212
import { createLogger } from '@objectstack/core';
@@ -26,7 +26,7 @@ export interface AIServiceConfig {
2626
/** Pre-registered tools. */
2727
toolRegistry?: ToolRegistry;
2828
/** Conversation service (defaults to InMemoryConversationService). */
29-
conversationService?: InMemoryConversationService;
29+
conversationService?: IAIConversationService;
3030
}
3131

3232
/**
@@ -39,7 +39,7 @@ export interface AIServiceConfig {
3939
* |:---|:---|
4040
* | {@link LLMAdapter} | LLM provider abstraction (chat, complete, stream, embed) |
4141
* | {@link ToolRegistry} | Tool definition storage & execution |
42-
* | {@link InMemoryConversationService} | Conversation CRUD & message persistence |
42+
* | {@link IAIConversationService} | Conversation CRUD & message persistence |
4343
*
4444
* The service is registered as `'ai'` in the kernel service registry by
4545
* the {@link AIServicePlugin}.
@@ -48,7 +48,7 @@ export class AIService implements IAIService {
4848
private readonly adapter: LLMAdapter;
4949
private readonly logger: Logger;
5050
readonly toolRegistry: ToolRegistry;
51-
readonly conversationService: InMemoryConversationService;
51+
readonly conversationService: IAIConversationService;
5252

5353
constructor(config: AIServiceConfig = {}) {
5454
this.adapter = config.adapter ?? new MemoryLLMAdapter();
@@ -81,7 +81,7 @@ export class AIService implements IAIService {
8181

8282
async *streamChat(
8383
messages: AIMessage[],
84-
options?: AIRequestOptionsWithTools,
84+
options?: AIRequestOptions,
8585
): AsyncIterable<AIStreamEvent> {
8686
this.logger.debug('[AI] streamChat', { messageCount: messages.length, model: options?.model });
8787

packages/services/service-ai/src/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class AIServicePlugin implements Plugin {
9696
await ctx.trigger('ai:ready', this.service);
9797

9898
// Build and expose route definitions
99-
const routes = buildAIRoutes(this.service, ctx.logger);
99+
const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
100100

101101
// Trigger hook so HTTP server plugins can mount these routes
102102
await ctx.trigger('ai:routes', routes);

0 commit comments

Comments
 (0)