Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **AI Chat Protocol Aligned with Vercel AI SDK** — Removed custom AI chat protocol
types and Zod schemas (`AIMessage`, `AIToolCall`, `AIStreamEvent`,
`AiChatRequestSchema`, `AiChatResponseSchema`) from `@objectstack/spec`. The
canonical message, tool-call, and streaming types are now re-exported from the
Vercel AI SDK (`ai` v6):
- `ModelMessage` replaces `AIMessage`
- `ToolCallPart` replaces `AIToolCall`
- `ToolResultPart` replaces `AIToolResult`
- `TextStreamPart<ToolSet>` replaces `AIStreamEvent`
- `IAIService` and `LLMAdapter` method signatures now accept `ModelMessage[]`
and return `TextStreamPart<ToolSet>` for streaming
- Deprecated type aliases preserved for migration convenience
- NLQ, Suggest, and Insights protocols (ObjectStack-specific) are retained
- **`@objectstack/service-ai` migrated to Vercel AI SDK types** — All source files
and tests now use canonical Vercel types (`ModelMessage`, `ToolCallPart`,
`ToolResultPart`, `TextStreamPart<ToolSet>`) instead of deprecated aliases:
- `ToolRegistry.execute()` accepts `ToolCallPart` and returns `ToolExecutionResult`
(extends `ToolResultPart` with `isError?: boolean`)
- Tool call loop in `AIService.chatWithTools()` constructs proper
`AssistantModelMessage` and `ToolModelMessage` with Vercel-format content arrays
- `MemoryLLMAdapter.streamChat()` emits Vercel `TextStreamPart<ToolSet>` events
- Conversation services serialize/deserialize `ModelMessage` union to flat DB columns
- All 158 service-ai tests updated and passing

### Removed
- `AiChatRequestSchema` / `AiChatResponseSchema` Zod schemas from
`@objectstack/spec/api` — the AI chat wire protocol now uses Vercel AI SDK's
data stream format (`toDataStreamResponse()`)
- `aiChat` method from `IObjectStackAPI` and client SDK — consumers should use
`@ai-sdk/react/useChat` directly
- AI `/chat` endpoint from `DEFAULT_AI_ROUTES` plugin REST API definition

### Added
- `ai` v6 as a dependency of `@objectstack/spec` for type re-exports

## [4.0.1] — 2026-03-31

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# ObjectStack Protocol — Road Map

> **Last Updated:** 2026-03-31
> **Last Updated:** 2026-04-01
> **Current Version:** v4.0.1
> **Status:** Protocol Specification Complete · Runtime Implementation In Progress

Expand Down
32 changes: 3 additions & 29 deletions content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,39 +36,13 @@ Architecture Alignment:
## TypeScript Usage

```typescript
import { AiChatRequest, AiChatResponse, AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
import type { AiChatRequest, AiChatResponse, AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
import { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
import type { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';

// Validate data
const result = AiChatRequest.parse(data);
const result = AiNlqRequest.parse(data);
```

---

## AiChatRequest

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **message** | `string` | ✅ | User message |
| **conversationId** | `string` | optional | Conversation ID for context |
| **context** | `Record<string, any>` | optional | Additional context data |


---

## AiChatResponse

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **message** | `string` | ✅ | Assistant response message |
| **conversationId** | `string` | ✅ | Conversation ID |
| **actions** | `Object[]` | optional | Suggested actions |


---

## AiInsightsRequest
Expand Down
16 changes: 1 addition & 15 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,6 @@ import {
MarkAllNotificationsReadResponse,
AiNlqRequest,
AiNlqResponse,
AiChatRequest,
AiChatResponse,
AiSuggestRequest,
AiSuggestResponse,
AiInsightsRequest,
Expand DownExpand Up@@ -1209,17 +1207,7 @@ export class ObjectStackClient {
return this.unwrapResponse<AiNlqResponse>(res);
},

/**
* Multi-turn AI chat
*/
chat: async (request: AiChatRequest): Promise<AiChatResponse> => {
const route = this.getRoute('ai');
const res = await this.fetch(`${this.baseUrl}${route}/chat`, {
method: 'POST',
body: JSON.stringify(request)
});
return this.unwrapResponse<AiChatResponse>(res);
},
// AI chat method removed — use Vercel AI SDK `useChat()` / `@ai-sdk/react` directly.

/**
* AI-powered field value suggestions
Expand DownExpand Up@@ -1826,8 +1814,6 @@ export type {
ListNotificationsResponse,
AiNlqRequest,
AiNlqResponse,
AiChatRequest,
AiChatResponse,
AiSuggestRequest,
AiSuggestResponse,
AiInsightsRequest,
Expand Down
51 changes: 27 additions & 24 deletions packages/services/service-ai/src/__tests__/ai-service.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AIMessage, IAIService, AIStreamEvent } from '@objectstack/spec/contracts';
import type { ModelMessage, IAIService, TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
import { AIService } from '../ai-service.js';
import { MemoryLLMAdapter } from '../adapters/memory-adapter.js';
import { ToolRegistry } from '../tools/tool-registry.js';
Expand DownExpand Up@@ -35,7 +35,7 @@ describe('MemoryLLMAdapter', () => {
});

it('should echo the last user message in chat()', async () => {
const messages: AIMessage[] = [
const messages: ModelMessage[] = [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Hello AI' },
];
Expand All@@ -46,7 +46,7 @@ describe('MemoryLLMAdapter', () => {
});

it('should handle no user message in chat()', async () => {
const messages: AIMessage[] = [{ role: 'system', content: 'System only' }];
const messages: ModelMessage[] = [{ role: 'system', content: 'System only' }];
const result = await adapter.chat(messages);
expect(result.content).toBe('[memory] (no user message)');
});
Expand All@@ -57,8 +57,8 @@ describe('MemoryLLMAdapter', () => {
});

it('should stream word-by-word in streamChat()', async () => {
const messages: AIMessage[] = [{ role: 'user', content: 'Hi there' }];
const events: AIStreamEvent[] = [];
const messages: ModelMessage[] = [{ role: 'user', content: 'Hi there' }];
const events: TextStreamPart<ToolSet>[] = [];
for await (const event of adapter.streamChat(messages)) {
events.push(event);
}
Expand DownExpand Up@@ -113,24 +113,26 @@ describe('ToolRegistry', () => {
);

const result = await registry.execute({
id: 'call_1',
name: 'add',
arguments: JSON.stringify({ a: 3, b: 4 }),
type: 'tool-call',
toolCallId: 'call_1',
toolName: 'add',
input: { a: 3, b: 4 },
});

expect(result.toolCallId).toBe('call_1');
expect(result.content).toBe('7');
expect(result.output).toEqual({ type: 'text', value: '7' });
expect(result.isError).toBeUndefined();
});

it('should return error for unknown tool', async () => {
const result = await registry.execute({
id: 'call_x',
name: 'unknown',
arguments: '{}',
type: 'tool-call',
toolCallId: 'call_x',
toolName: 'unknown',
input: {},
});
expect(result.isError).toBe(true);
expect(result.content).toContain('not registered');
expect(result.output).toEqual(expect.objectContaining({ type: 'text', value: expect.stringContaining('not registered') }));
});

it('should return error on handler failure', async () => {
Expand All@@ -140,12 +142,13 @@ describe('ToolRegistry', () => {
);

const result = await registry.execute({
id: 'call_f',
name: 'fail_tool',
arguments: '{}',
type: 'tool-call',
toolCallId: 'call_f',
toolName: 'fail_tool',
input: {},
});
expect(result.isError).toBe(true);
expect(result.content).toBe('boom');
expect(result.output).toEqual({ type: 'text', value: 'boom' });
});

it('should execute multiple tool calls in parallel', async () => {
Expand All@@ -155,13 +158,13 @@ describe('ToolRegistry', () => {
);

const results = await registry.executeAll([
{ id: 'c1', name: 'echo', arguments: '{"msg":"a"}' },
{ id: 'c2', name: 'echo', arguments: '{"msg":"b"}' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'echo', input: { msg: 'a' } },
{ type: 'tool-call', toolCallId: 'c2', toolName: 'echo', input: { msg: 'b' } },
]);

expect(results).toHaveLength(2);
expect(results[0].content).toBe('a');
expect(results[1].content).toBe('b');
expect(results[0].output).toEqual({ type: 'text', value: 'a' });
expect(results[1].output).toEqual({ type: 'text', value: 'b' });
});

it('should return all definitions', () => {
Expand DownExpand Up@@ -272,7 +275,7 @@ describe('AIService', () => {

it('should stream via adapter.streamChat()', async () => {
const service = new AIService({ logger: silentLogger });
const events: AIStreamEvent[] = [];
const events: TextStreamPart<ToolSet>[] = [];
for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
events.push(event);
}
Expand All@@ -289,14 +292,14 @@ describe('AIService', () => {
};
const service = new AIService({ adapter, logger: silentLogger });

const events: AIStreamEvent[] = [];
const events: TextStreamPart<ToolSet>[] = [];
for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
events.push(event);
}

expect(events).toHaveLength(2);
expect(events[0].type).toBe('text-delta');
expect(events[0].textDelta).toBe('response');
expect(events[0].type === 'text-delta' && events[0].text).toBe('response');
expect(events[1].type).toBe('finish');
});

Expand Down
Loading
Loading