This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new d01a1c3db8 [ZEPPELIN-6742] Connect the AI Assistant frontend to
conversation REST and WebSocket APIs
d01a1c3db8 is described below
commit d01a1c3db804a036f319e2652b17196c7fdade8b
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Oct 18 16:07:31 2026 +0900
[ZEPPELIN-6742] Connect the AI Assistant frontend to conversation REST and
WebSocket APIs
### What is this PR for?
Connect the Assistant frontend to the conversation REST and WebSocket APIs.
The transport provides conversation creation and retrieval, message sending,
and response delivery for the UI.
Server PR #5547, #5558, and UI contracts #5561 have merged. This PR is now
based on master.
### What type of PR is it?
Feature
### Todos
* [x] Merge #5547
* [x] Merge #5558
* [x] Merge #5561 before this PR
* [x] Rebase onto master, retarget this PR to master, and rerun CI before
merging
* [x] Compare against the final API contract from server PR #5547
* [x] Confirm CI on the current PR head
### What is the Jira issue?
ZEPPELIN-6742
### How should this be tested?
Run from `zeppelin-web-angular/`:
```bash
npm run build-project:sdk
npm run typecheck:react
npm --prefix projects/zeppelin-react test --
src/entities/assistant/model/assistantTransport.spec.ts
```
Also verify the REST and WebSocket integration against the final API
contract from server PR #5547.
### Screenshots (if appropriate)
N/A
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5563 from voidmatcha/voidmatcha/assistant-v2-6-transport.
Signed-off-by: ChanHo Lee <[email protected]>
---
.../entities/assistant/model/assistantContract.ts | 13 +-
.../assistant/model/assistantTransport.spec.ts | 658 +++++++++++++++++++++
.../entities/assistant/model/assistantTransport.ts | 346 +++++++++++
.../projects/zeppelin-sdk/src/assistant.ts | 27 +
.../projects/zeppelin-sdk/src/public-api.ts | 1 +
5 files changed, 1034 insertions(+), 11 deletions(-)
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts
index e2792bdf48..701e9dbfd2 100644
---
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts
@@ -48,9 +48,6 @@ export interface AssistantMessagePage {
/** Pass as `before` to load older messages; null at the start of the
conversation. */
cursor: string | null;
}
-export interface AssistantRunBody {
- content: string;
-}
export type AssistantRunEvent =
| { type: 'run.started'; runId?: string }
| { type: 'run.completed'; runId?: string }
@@ -60,19 +57,13 @@ export type AssistantRunEvent =
| { type: 'tool_call.started'; toolCallId: string; name: string }
| { type: 'tool_call.done'; toolCallId: string };
-export type AssistantRunState = 'idle' | 'running' | 'disconnected';
-
-export interface AssistantTransport {
- /** Conversations of the note the transport was created for. */
+export interface AssistantConversationApi {
+ /** Conversations of the note the API was created for. */
listConversations(): Promise<AssistantConversation[]>;
createConversation(body: { title?: string }): Promise<AssistantConversation>;
deleteConversation(conversationId: string): Promise<void>;
/** The latest page of history, or the page before `before` (a page cursor).
*/
getMessages(conversationId: string, before?: string):
Promise<AssistantMessagePage>;
- /** Observe local run state, including a lost connection until history is
reloaded. */
- subscribeRunState?(conversationId: string, listener: (state:
AssistantRunState) => void): () => void;
- /** Aborting stops local event consumption; it does not cancel a run already
accepted by the server. */
- openRun(conversationId: string, body: AssistantRunBody, signal:
AbortSignal): AsyncIterable<AssistantRunEvent>;
}
export class AssistantStreamError extends Error {
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.spec.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.spec.ts
new file mode 100644
index 0000000000..e1e763c3a9
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.spec.ts
@@ -0,0 +1,658 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { AssistantSendMessage, AssistantSocketEvent } from
'@zeppelin/sdk';
+
+import {
+ AssistantHttpError,
+ createAssistantConversationApi,
+ mapSocketEvent,
+ socketRun,
+ toVisibleMessages
+} from './assistantTransport';
+import {
+ AssistantConnectionError,
+ AssistantStreamError,
+ type AssistantAnswerMessage,
+ type AssistantRunEvent
+} from './assistantContract';
+import { answerText, answerToolCalls, joinPages } from './messageHistory';
+
+/** A host socket double: records sends and lets a test emit server events. */
+const fakeSocket = () => {
+ const listeners = new Set<(event: AssistantSocketEvent) => void>();
+ const closeListeners = new Set<() => void>();
+ const sent: AssistantSendMessage[] = [];
+ return {
+ sent,
+ listenerCount: () => listeners.size,
+ emit: (event: AssistantSocketEvent) => listeners.forEach(listener =>
listener(event)),
+ send: vi.fn((message: AssistantSendMessage) => {
+ sent.push(message);
+ }),
+ subscribe: vi.fn((listener: (event: AssistantSocketEvent) => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }),
+ close: () => closeListeners.forEach(listener => listener()),
+ closeListenerCount: () => closeListeners.size,
+ subscribeClose: vi.fn((listener: () => void) => {
+ closeListeners.add(listener);
+ return () => closeListeners.delete(listener);
+ })
+ };
+};
+
+const forNote = (noteId: string, onAuthError?: (status: number) => void) =>
+ createAssistantConversationApi('https://example.test/zeppelin/api', noteId,
onAuthError);
+
+const iterate = <T>(iterable: AsyncIterable<T>): AsyncIterator<T> =>
iterable[Symbol.asyncIterator]();
+
+const collect = async (events: AsyncIterable<AssistantRunEvent>):
Promise<AssistantRunEvent[]> => {
+ const collected: AssistantRunEvent[] = [];
+ for await (const event of events) {
+ collected.push(event);
+ }
+ return collected;
+};
+
+const message = (content = 'hi'): AssistantSendMessage => ({ noteId: 'n',
conversationId: 'c1', content });
+
+describe('assistant transport', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ it('lists the most recently active conversation first', async () => {
+ const body = [
+ { id: 'old', ownerId: 'alice', canSendMessage: true, updatedAt:
'2026-10-01T00:00:00Z' },
+ { id: 'new', ownerId: 'bob', canSendMessage: false, updatedAt:
'2026-10-03T00:00:00Z' },
+ { id: 'middle', ownerId: 'carol', canSendMessage: true, updatedAt:
'2026-10-02T00:00:00Z' }
+ ];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ status: 'OK', body }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+ )
+ );
+ const conversations = await forNote('note-1').listConversations();
+ expect(conversations.map(conversation => conversation.id)).toEqual(['new',
'middle', 'old']);
+ expect(conversations.map(conversation => conversation.updatedAt)).toEqual([
+ '2026-10-03T00:00:00Z',
+ '2026-10-02T00:00:00Z',
+ '2026-10-01T00:00:00Z'
+ ]);
+ expect(conversations[0]).toMatchObject({ ownerId: 'bob', canSendMessage:
false });
+ });
+
+ it('uses the per-note conversation endpoints and unwraps Zeppelin JSON
responses', async () => {
+ const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type':
'application/json' } });
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ json({
+ status: 'OK',
+ body: [
+ {
+ id: 'conv-1',
+ title: 'First',
+ noteId: 'note/one',
+ createdAt: '2026-09-30T00:00:00Z',
+ ownerId: 'alice',
+ canSendMessage: false,
+ updatedAt: '2026-10-01T00:00:00Z'
+ }
+ ]
+ })
+ )
+ .mockResolvedValueOnce(
+ json(
+ {
+ status: 'CREATED',
+ body: {
+ id: 'conv-2',
+ title: 'Load the data',
+ noteId: 'note/one',
+ ownerId: 'bob',
+ canSendMessage: true,
+ createdAt: '2026-10-02T00:00:00Z',
+ updatedAt: '2026-10-02T00:00:00Z'
+ }
+ },
+ 201
+ )
+ )
+ .mockResolvedValueOnce(
+ json({
+ status: 'OK',
+ body: {
+ messages: [
+ { id: 'message-2', role: 'assistant', content: 'hi there' },
+ { id: 'message-1', role: 'user', content: 'hello' }
+ ]
+ }
+ })
+ )
+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
+ vi.stubGlobal('fetch', fetchMock);
+ const transport = forNote('note/one');
+
+ await expect(transport.listConversations()).resolves.toEqual([
+ { id: 'conv-1', title: 'First', ownerId: 'alice', canSendMessage: false,
updatedAt: '2026-10-01T00:00:00Z' }
+ ]);
+ await expect(transport.createConversation({ title: ' Load the data '
})).resolves.toEqual({
+ id: 'conv-2',
+ title: 'Load the data',
+ ownerId: 'bob',
+ canSendMessage: true,
+ updatedAt: '2026-10-02T00:00:00Z'
+ });
+ // The server pages latest first; the panel wants oldest first.
+ await expect(transport.getMessages('conv/1')).resolves.toEqual({
+ messages: [
+ { id: 'message-1', role: 'user', content: 'hello' },
+ {
+ id: 'message-2',
+ role: 'assistant',
+ segments: [{ id: 'message-2', content: 'hi there' }]
+ }
+ ],
+ cursor: null
+ });
+ await
expect(transport.deleteConversation('conv/1')).resolves.toBeUndefined();
+
+ const base =
'https://example.test/zeppelin/api/notes/note%2Fone/conversations';
+ expect(fetchMock.mock.calls.map(([url, init]) => [url,
init.method])).toEqual([
+ [base, 'GET'],
+ [base, 'POST'],
+ [`${base}/conv%2F1/messages?limit=50`, 'GET'],
+ [`${base}/conv%2F1`, 'DELETE']
+ ]);
+ expect(fetchMock.mock.calls[1][1]).toMatchObject({
+ credentials: 'include',
+ headers: expect.objectContaining({ 'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json' }),
+ body: JSON.stringify({ title: ' Load the data ' })
+ });
+ });
+
+ it('preserves a long conversation title when creating it', async () => {
+ const title = ` ${'A'.repeat(85)} `;
+ const fetchMock = vi.fn().mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ status: 'CREATED',
+ body: { id: 'conv-1', title, ownerId: 'alice', canSendMessage: true,
updatedAt: '2026-10-02T00:00:00Z' }
+ }),
+ { status: 201 }
+ )
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(forNote('n').createConversation({ title
})).resolves.toMatchObject({ title });
+ expect(fetchMock.mock.calls[0][1].body).toBe(JSON.stringify({ title }));
+ });
+
+ it('words a 404 on the conversation list as a server without the assistant',
async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 404 }))
+ );
+ const transport = forNote('n');
+
+ await expect(transport.listConversations()).rejects.toThrow('The assistant
is not available on this server');
+ await expect(transport.createConversation({})).rejects.toThrow('The
assistant is not available on this server');
+ await expect(transport.getMessages('c')).rejects.toThrow('This
conversation or note no longer exists.');
+ });
+
+ it('hands 401 and 405 to the host, but not other failures', async () => {
+ const onAuthError = vi.fn();
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
+ .mockResolvedValueOnce(new Response(null, { status: 405 }))
+ .mockResolvedValueOnce(new Response(null, { status: 500 }))
+ );
+ const transport = forNote('n', onAuthError);
+
+ await expect(transport.listConversations()).rejects.toMatchObject({
status: 401 });
+ await expect(transport.createConversation({})).rejects.toMatchObject({
status: 405 });
+ await
expect(transport.getMessages('c')).rejects.toBeInstanceOf(AssistantHttpError);
+
+ expect(onAuthError.mock.calls).toEqual([[401], [405]]);
+ });
+
+ it('sends over the socket and yields only this conversation until a terminal
event', async () => {
+ const socket = fakeSocket();
+ const run = iterate(
+ socketRun(socket, { noteId: 'n', conversationId: 'c1', content:
'improve' }, new AbortController().signal)
+ );
+ const first = run.next();
+ await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
+ expect(socket.sent[0]).toEqual({ noteId: 'n', conversationId: 'c1',
content: 'improve' });
+
+ socket.emit({ conversationId: 'other', type: 'message.delta', payload: {
messageId: 'x', delta: 'no' } });
+ socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId:
'run_1' } });
+ socket.emit({
+ conversationId: 'c1',
+ type: 'tool_call.started',
+ payload: { toolCallId: 't1', name: 'list_paragraphs' }
+ });
+ socket.emit({
+ conversationId: 'c1',
+ type: 'tool_call.done',
+ payload: { toolCallId: 't1', result: { value: '[]' } }
+ });
+ // A newer server may send types this client does not know.
+ socket.emit({ conversationId: 'c1', type: 'usage.extra', payload: {} } as
unknown as AssistantSocketEvent);
+ socket.emit({ conversationId: 'c1', type: 'message.delta', payload: {
messageId: 'm1', delta: '한글' } });
+ socket.emit({ conversationId: 'c1', type: 'run.completed', payload: {
runId: 'run_1' } });
+ socket.emit({ conversationId: 'c1', type: 'message.delta', payload: {
messageId: 'm2', delta: 'late' } });
+
+ const events = [(await first).value];
+ for (let next = await run.next(); !next.done; next = await run.next())
events.push(next.value);
+ expect(events).toEqual([
+ { type: 'run.started', runId: 'run_1' },
+ { type: 'tool_call.started', toolCallId: 't1', name: 'list_paragraphs' },
+ { type: 'tool_call.done', toolCallId: 't1' },
+ { type: 'message.delta', messageId: 'm1', delta: '한글' },
+ { type: 'run.completed', runId: 'run_1' }
+ ]);
+ expect(socket.listenerCount()).toBe(0);
+ });
+
+ it('ends on a run failure that arrives before run.started', async () => {
+ const socket = fakeSocket();
+ const pending = collect(socketRun(socket, message(), new
AbortController().signal));
+ socket.emit({
+ conversationId: 'c1',
+ type: 'run.failed',
+ payload: { runId: 'run_x', error: { status: 403 } }
+ });
+ await expect(pending).resolves.toEqual([
+ {
+ type: 'run.failed',
+ runId: 'run_x',
+ message: 'You do not have permission to send messages to this
conversation.'
+ }
+ ]);
+ });
+
+ it('sends again after an abandoned request and leaves a duplicate to the
server', async () => {
+ const socket = fakeSocket();
+ const leave = new AbortController();
+ const first = iterate(socketRun(socket, message('first'), leave.signal));
+ const response = first.next();
+ leave.abort();
+ await expect(response).rejects.toMatchObject({ name: 'AbortError' });
+ expect(socket.listenerCount()).toBe(0);
+ expect(socket.closeListenerCount()).toBe(0);
+
+ const second = collect(socketRun(socket, message('second'), new
AbortController().signal));
+ expect(socket.sent).toHaveLength(2);
+ socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId:
'second', error: { status: 409 } } });
+ await expect(second).resolves.toEqual([
+ {
+ type: 'run.failed',
+ runId: 'second',
+ message: 'This conversation is still answering. Try again when it
finishes.'
+ }
+ ]);
+ });
+
+ it('ends the run and releases its listeners when the connection closes',
async () => {
+ const socket = fakeSocket();
+ const pending = collect(socketRun(socket, message(), new
AbortController().signal));
+ socket.close();
+ await expect(pending).rejects.toBeInstanceOf(AssistantConnectionError);
+ expect(socket.listenerCount()).toBe(0);
+ expect(socket.closeListenerCount()).toBe(0);
+ });
+
+ it('rethrows a failed send and releases its listeners', async () => {
+ const socket = fakeSocket();
+ socket.send.mockImplementationOnce(() => {
+ throw new DOMException('Notebook changed', 'AbortError');
+ });
+ await expect(collect(socketRun(socket, message(), new
AbortController().signal))).rejects.toMatchObject({
+ name: 'AbortError'
+ });
+ expect(socket.listenerCount()).toBe(0);
+ expect(socket.closeListenerCount()).toBe(0);
+ });
+
+ it('keeps an active run going when a duplicate send is rejected with 409',
async () => {
+ const socket = fakeSocket();
+ const first = iterate(socketRun(socket, message('first'), new
AbortController().signal));
+ const firstEvent = first.next();
+ socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId:
'run_old' } });
+ expect((await firstEvent).value).toEqual({ type: 'run.started', runId:
'run_old' });
+
+ const second = collect(socketRun(socket, message('second'), new
AbortController().signal));
+ expect(socket.sent).toHaveLength(2);
+ socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId:
'run_dup', error: { status: 409 } } });
+ await expect(second).resolves.toEqual([
+ {
+ type: 'run.failed',
+ runId: 'run_dup',
+ message: 'This conversation is still answering. Try again when it
finishes.'
+ }
+ ]);
+
+ socket.emit({ conversationId: 'c1', type: 'message.delta', payload: {
messageId: 'old', delta: 'tail' } });
+ socket.emit({ conversationId: 'c1', type: 'run.completed', payload: {
runId: 'run_old' } });
+ expect((await first.next()).value).toEqual({ type: 'message.delta',
messageId: 'old', delta: 'tail' });
+ expect((await first.next()).value).toEqual({ type: 'run.completed', runId:
'run_old' });
+ expect((await first.next()).done).toBe(true);
+ });
+
+ it('words run.failed by its HTTP status and falls back for the rest', () => {
+ const failed = (error: unknown) =>
+ mapSocketEvent({ conversationId: 'c', type: 'run.failed', payload: {
runId: 'r', error } });
+ expect(failed({ status: 409 })).toEqual({
+ type: 'run.failed',
+ runId: 'r',
+ message: 'This conversation is still answering. Try again when it
finishes.'
+ });
+ expect(failed({ status: 400 })).toMatchObject({
+ message: 'The assistant could not accept this message. Edit it and try
again.'
+ });
+ expect(failed({ status: 503 })).toMatchObject({ message:
expect.stringContaining('not configured') });
+ expect(failed({ status: 500 })).toMatchObject({
+ message: 'The assistant hit an error while answering. Try again.'
+ });
+ expect(failed(undefined)).toMatchObject({ message: 'The assistant hit an
error while answering. Try again.' });
+ expect(failed(null)).toMatchObject({ message: 'The assistant hit an error
while answering. Try again.' });
+ expect(failed(403)).toMatchObject({ message: 'The assistant hit an error
while answering. Try again.' });
+ expect(failed({ status: '403' })).toMatchObject({
+ message: 'The assistant hit an error while answering. Try again.'
+ });
+ });
+
+ it('loads an earlier page with the cursor and joins a turn split across
pages', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ status: 'OK',
+ body: { messages: [{ id: 'm3', role: 'assistant', content: 'part
one.' }], cursor: 'm3' }
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const page = await forNote('n').getMessages('c', 'm9');
+ expect(fetchMock.mock.calls[0][0]).toBe(
+
'https://example.test/zeppelin/api/notes/n/conversations/c/messages?limit=50&cursor=m9'
+ );
+ expect(page).toEqual({
+ messages: [{ id: 'm3', role: 'assistant', segments: [{ id: 'm3',
content: 'part one.' }] }],
+ cursor: 'm3'
+ });
+ expect(
+ joinPages(page.messages, [
+ { id: 'm5', role: 'assistant', segments: [{ id: 'm5', content: 'part
two.' }] },
+ { id: 'm6', role: 'user', content: 'next' }
+ ])
+ ).toEqual([
+ {
+ id: 'm5',
+ role: 'assistant',
+ segments: [
+ { id: 'm3', content: 'part one.' },
+ { id: 'm5', content: 'part two.' }
+ ]
+ },
+ { id: 'm6', role: 'user', content: 'next' }
+ ]);
+ });
+
+ it('explains a missing assistant configuration instead of the bare status',
() => {
+ expect(new AssistantHttpError(503).message).toBe(
+ 'The assistant is not configured on this server. Ask an administrator to
enable it.'
+ );
+ expect(new AssistantHttpError(500).message).toBe('Assistant request failed
with HTTP 500');
+ });
+
+ it('releases every listener when aborted before the run starts', async () =>
{
+ const socket = fakeSocket();
+ const controller = new AbortController();
+ const run = iterate(socketRun(socket, message(), controller.signal));
+ const pending = run.next();
+ await vi.waitFor(() => expect(socket.listenerCount()).toBe(1));
+
+ controller.abort();
+
+ await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
+ expect(socket.listenerCount()).toBe(0);
+ expect(socket.closeListenerCount()).toBe(0);
+ });
+
+ it('ends a run with a connection error when the notebook socket closes',
async () => {
+ const socket = fakeSocket();
+ const pending = collect(socketRun(socket, message(), new
AbortController().signal));
+ socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId:
'r1' } });
+ socket.close();
+ await expect(pending).rejects.toBeInstanceOf(AssistantConnectionError);
+ expect(socket.closeListenerCount()).toBe(0);
+ });
+
+ it('reports a retryable error and releases its listeners when the run goes
quiet past the idle timeout', async () => {
+ vi.useFakeTimers();
+ const socket = fakeSocket();
+ const pending = collect(socketRun(socket, message(), new
AbortController().signal, 1000));
+ const outcome = pending.then(
+ () => null,
+ (error: unknown) => error
+ );
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(await outcome).toBeInstanceOf(AssistantStreamError);
+ expect(socket.listenerCount()).toBe(0);
+ expect(socket.closeListenerCount()).toBe(0);
+ });
+
+ it('fails the run on an event with a malformed payload', async () => {
+ const socket = fakeSocket();
+ const pending = collect(socketRun(socket, message(), new
AbortController().signal));
+ socket.emit({ conversationId: 'c1', type: 'message.delta', payload: {
messageId: 'm1' } });
+ // Told in words for the user; the field stays on the error for debugging.
+ await expect(pending).rejects.toThrow('The assistant sent a reply this
panel could not read. Try again.');
+ await expect(pending).rejects.toMatchObject({ detail: 'Assistant event
field "delta" is required' });
+ });
+
+ it('ignores event types the server does not define', () => {
+ expect(
+ mapSocketEvent({
+ conversationId: 'c',
+ type: 'proposal.created',
+ payload: { kind: 'insert', text: 'x' }
+ } as unknown as AssistantSocketEvent)
+ ).toBeUndefined();
+ expect(
+ mapSocketEvent({
+ conversationId: 'c',
+ type: 'ui.reveal',
+ payload: { paragraphId: 'p1' }
+ } as unknown as AssistantSocketEvent)
+ ).toBeUndefined();
+ });
+
+ it('shows one bubble per turn and hides tool and system messages', () => {
+ expect(
+ toVisibleMessages([
+ { id: 'u1', role: 'user', content: 'improve' },
+ { id: 'a1', role: 'assistant', content: '' },
+ { id: 't1', role: 'tool', content: '{}' },
+ { id: 'a2', role: 'assistant', content: 'Reading.' },
+ { id: 't2', role: 'tool', content: '{}' },
+ { id: 'a3', role: 'assistant', content: 'Done.' },
+ { id: 's1', role: 'system', content: 'hidden' }
+ ])
+ ).toEqual([
+ { id: 'u1', role: 'user', content: 'improve' },
+ // The merged bubble keeps the id of its last message.
+ {
+ id: 'a3',
+ role: 'assistant',
+ segments: [
+ { id: 'a2', content: 'Reading.' },
+ { id: 'a3', content: 'Done.' }
+ ]
+ }
+ ]);
+ });
+
+ it('keeps the tool calls of a turn on its answer, including across pages',
() => {
+ const visible = toVisibleMessages([
+ { id: 'u1', role: 'user', content: 'what is here?' },
+ { id: 'a1', role: 'assistant', content: '', toolCalls: [{ id: 'c1',
name: 'list_paragraphs' }] },
+ { id: 't1', role: 'tool', content: '[]' },
+ { id: 'a2', role: 'assistant', content: 'Two paragraphs.' },
+ { id: 'u2', role: 'user', content: 'thanks' },
+ { id: 'a3', role: 'assistant', content: 'You are welcome.' }
+ ]);
+ expect(visible[1]).toEqual({
+ id: 'a2',
+ role: 'assistant',
+ segments: [
+ { id: 'a1', content: '', toolCalls: [{ id: 'c1', name:
'list_paragraphs' }] },
+ { id: 'a2', content: 'Two paragraphs.' }
+ ]
+ });
+ expect(answerToolCalls(visible[1] as AssistantAnswerMessage)).toEqual([{
id: 'c1', name: 'list_paragraphs' }]);
+ expect(answerText(visible[1] as AssistantAnswerMessage)).toBe('Two
paragraphs.');
+ expect(answerToolCalls(visible[3] as AssistantAnswerMessage)).toEqual([]);
+ });
+
+ it('preserves the order of interleaved answer and tool rounds from server
history', () => {
+ const visible = toVisibleMessages([
+ { id: 'u1', role: 'user', content: 'Investigate' },
+ { id: 'a1', role: 'assistant', content: 'First.', toolCalls: [{ id:
'c1', name: 'list_paragraphs' }] },
+ { id: 't1', role: 'tool', content: '[]' },
+ { id: 'a2', role: 'assistant', content: 'Next.', toolCalls: [{ id: 'c2',
name: 'read_paragraph' }] },
+ { id: 't2', role: 'tool', content: '{}' },
+ { id: 'a3', role: 'assistant', content: 'Final.' }
+ ]);
+ expect(visible[1]).toMatchObject({
+ id: 'a3',
+ segments: [
+ { id: 'a1', content: 'First.', toolCalls: [{ id: 'c1', name:
'list_paragraphs' }] },
+ { id: 'a2', content: 'Next.', toolCalls: [{ id: 'c2', name:
'read_paragraph' }] },
+ { id: 'a3', content: 'Final.' }
+ ]
+ });
+ });
+
+ it('keeps tool-only messages until an answer arrives, including across raw
history pages', () => {
+ const question = { id: 'u1', role: 'user' as const, content: 'Read this
notebook' };
+ const call = {
+ id: 'a1',
+ role: 'assistant' as const,
+ content: null,
+ toolCalls: [{ id: 'c1', name: 'list_paragraphs' }]
+ };
+ const result = { id: 't1', role: 'tool' as const, content: '[]' };
+ const answer = { id: 'a2', role: 'assistant' as const, content: 'Two
paragraphs.' };
+ const earlier = toVisibleMessages([question, call]);
+ expect(earlier[1]).toEqual({
+ id: 'a1',
+ role: 'assistant',
+ segments: [{ id: 'a1', content: '', toolCalls: call.toolCalls }]
+ });
+ expect(joinPages(earlier, toVisibleMessages([result, answer]))).toEqual(
+ toVisibleMessages([question, call, result, answer])
+ );
+ // An intervening page containing only tool results must not lose the
pending calls either.
+ expect(joinPages(joinPages(earlier, toVisibleMessages([result])),
toVisibleMessages([answer]))).toEqual([
+ question,
+ {
+ id: answer.id,
+ role: answer.role,
+ segments: [
+ { id: 'a1', content: '', toolCalls: call.toolCalls },
+ { id: 'a2', content: 'Two paragraphs.' }
+ ]
+ }
+ ]);
+ const nextTurn = [
+ { id: 'u2', role: 'user' as const, content: 'Another question' },
+ { id: 'a3', role: 'assistant' as const, content: 'Another answer' }
+ ];
+ expect(joinPages(earlier, toVisibleMessages(nextTurn))).toEqual([
+ ...earlier,
+ nextTurn[0],
+ { id: 'a3', role: 'assistant', segments: [{ id: 'a3', content: 'Another
answer' }] }
+ ]);
+ expect(toVisibleMessages([question, call, ...nextTurn])).toEqual([
+ ...earlier,
+ nextTurn[0],
+ { id: 'a3', role: 'assistant', segments: [{ id: 'a3', content: 'Another
answer' }] }
+ ]);
+ });
+
+ it('keeps a trailing tool round after answer text and deduplicates call ids
at page boundaries', () => {
+ const call = { id: 'c1', name: 'list_paragraphs' };
+ const earlier = toVisibleMessages([
+ { id: 'a1', role: 'assistant', content: 'Looking.' },
+ { id: 'a2', role: 'assistant', content: '', toolCalls: [call] }
+ ]);
+ expect(earlier).toEqual([
+ {
+ id: 'a2',
+ role: 'assistant',
+ segments: [
+ { id: 'a1', content: 'Looking.' },
+ { id: 'a2', content: '', toolCalls: [call] }
+ ]
+ }
+ ]);
+ const later = toVisibleMessages([
+ { id: 'a2', role: 'assistant', content: '', toolCalls: [call] },
+ { id: 'a3', role: 'assistant', content: 'Done.' }
+ ]);
+ expect(joinPages(earlier, later)).toEqual([
+ {
+ id: 'a3',
+ role: 'assistant',
+ segments: [
+ { id: 'a1', content: 'Looking.' },
+ { id: 'a2', content: '', toolCalls: [call] },
+ { id: 'a3', content: 'Done.' }
+ ]
+ }
+ ]);
+ });
+});
+
+describe('conversation list', () => {
+ it('uses the server conversation response without a running field', async ()
=> {
+ const conversation = {
+ id: 'conversation',
+ title: 'Question',
+ ownerId: 'alice',
+ canSendMessage: true,
+ updatedAt: '2026-10-01T00:00:00Z'
+ };
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
body: [conversation] })));
+ vi.stubGlobal('fetch', fetch);
+ const transport = createAssistantConversationApi('/api', 'note');
+ expect(await transport.listConversations()).toEqual([conversation]);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.ts
new file mode 100644
index 0000000000..ef6f0951a6
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.ts
@@ -0,0 +1,346 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { AssistantSendMessage, AssistantSocket, AssistantSocketEvent }
from '@zeppelin/sdk';
+import {
+ AssistantConnectionError,
+ AssistantStreamError,
+ type AssistantConversation,
+ type AssistantConversationApi,
+ type AssistantMessage,
+ type AssistantMessagePage,
+ type AssistantRunEvent,
+ type AssistantToolCallRef
+} from './assistantContract';
+import { mergeAnswers } from './messageHistory';
+
+const HTTP_MESSAGES: Record<number, string> = {
+ 403: 'You do not have permission for this conversation.',
+ 404: 'This conversation or note no longer exists.',
+ 409: 'This conversation is still answering. Try again when it finishes.',
+ 503: 'The assistant is not configured on this server. Ask an administrator
to enable it.'
+};
+
+// run.failed carries only the HTTP status of the rejected or failed run, so
the UI words it.
+const RUN_ERROR_MESSAGES: Record<number, string> = {
+ ...HTTP_MESSAGES,
+ 400: 'The assistant could not accept this message. Edit it and try again.',
+ 429: 'The assistant is busy. Try again shortly.',
+ // Also returned when the user lost read access to the note, so it does not
name the owner.
+ 403: 'You do not have permission to send messages to this conversation.'
+};
+const NOT_AVAILABLE_MESSAGE = 'The assistant is not available on this server,
or this note no longer exists.';
+const RUN_ERROR_FALLBACK = 'The assistant hit an error while answering. Try
again.';
+
+export class AssistantHttpError extends Error {
+ constructor(
+ public readonly status: number,
+ message = HTTP_MESSAGES[status] ?? `Assistant request failed with HTTP
${status}`
+ ) {
+ super(message);
+ this.name = 'AssistantHttpError';
+ }
+}
+
+/** An event this panel cannot read; the field stays on the error for
debugging, the message is for the user. */
+export class AssistantProtocolError extends Error {
+ constructor(readonly detail: string) {
+ super('The assistant sent a reply this panel could not read. Try again.');
+ this.name = 'AssistantProtocolError';
+ }
+}
+
+// The server sends no heartbeat, so a run that goes quiet this long is
treated as lost.
+const RUN_IDLE_TIMEOUT_MS = 180_000;
+
+const isRecord = (value: unknown): value is Record<string, unknown> => typeof
value === 'object' && value !== null;
+
+const optionalString = (data: Record<string, unknown>, field: string): string
| undefined => {
+ const value = data[field];
+ if (value !== undefined && typeof value !== 'string') {
+ throw new AssistantProtocolError(`Assistant event field "${field}" must be
a string`);
+ }
+ return value;
+};
+
+const requiredString = (data: Record<string, unknown>, field: string): string
=> {
+ const value = optionalString(data, field);
+ if (value === undefined) {
+ throw new AssistantProtocolError(`Assistant event field "${field}" is
required`);
+ }
+ return value;
+};
+
+const errorMessage = (data: Record<string, unknown>): string => {
+ const error = data.error;
+ const status = isRecord(error) ? error.status : undefined;
+ return (typeof status === 'number' && RUN_ERROR_MESSAGES[status]) ||
RUN_ERROR_FALLBACK;
+};
+
+/** Maps an ASSISTANT_EVENT to the UI contract; unknown types map to
undefined, so new server events are ignored. */
+export const mapSocketEvent = ({ type, payload }: AssistantSocketEvent):
AssistantRunEvent | undefined => {
+ const data = isRecord(payload) ? payload : {};
+ switch (type) {
+ case 'run.started':
+ return { type, runId: optionalString(data, 'runId') };
+ case 'run.completed':
+ return { type, runId: optionalString(data, 'runId') };
+ case 'run.failed':
+ return { type, runId: optionalString(data, 'runId'), message:
errorMessage(data) };
+ case 'message.delta':
+ return { type, messageId: requiredString(data, 'messageId'), delta:
requiredString(data, 'delta') };
+ case 'message.done':
+ return { type, messageId: requiredString(data, 'messageId'), content:
requiredString(data, 'content') };
+ case 'tool_call.started':
+ return { type, toolCallId: requiredString(data, 'toolCallId'), name:
requiredString(data, 'name') };
+ case 'tool_call.done':
+ return { type, toolCallId: requiredString(data, 'toolCallId') };
+ default:
+ return undefined;
+ }
+};
+
+const isTerminal = (event: AssistantRunEvent): boolean => event.type ===
'run.completed' || event.type === 'run.failed';
+
+interface ConversationMessage {
+ id: string;
+ role: 'user' | 'assistant' | 'tool' | 'system';
+ content?: string | null;
+ toolCalls?: Array<{ id?: unknown; name?: unknown }> | null;
+}
+
+const toolCallRefs = (message: ConversationMessage): AssistantToolCallRef[] =>
+ (message.toolCalls ?? []).flatMap(call =>
+ typeof call?.id === 'string' && typeof call.name === 'string' ? [{ id:
call.id, name: call.name }] : []
+ );
+
+/**
+ * One bubble per turn: tool and system messages are hidden, tool rounds merge
under the last message's id, and
+ * the tools called on the way are kept on the answer so a reopened
conversation still shows its action log.
+ */
+export const toVisibleMessages = (messages: ConversationMessage[]):
AssistantMessage[] => {
+ const visible: AssistantMessage[] = [];
+ for (const message of messages) {
+ if (message.role === 'user') {
+ visible.push({ id: message.id, role: 'user', content: message.content ??
'' });
+ } else if (message.role === 'assistant') {
+ const toolCalls = toolCallRefs(message);
+ // Keep a tool-only turn even when its answer is on a later page or has
not arrived yet.
+ if (!message.content && !toolCalls.length) continue;
+ const last = visible[visible.length - 1];
+ const answer: AssistantMessage = {
+ id: message.id,
+ role: 'assistant',
+ segments: [{ id: message.id, content: message.content ?? '',
...(toolCalls.length ? { toolCalls } : {}) }]
+ };
+ if (last?.role === 'assistant') visible[visible.length - 1] =
mergeAnswers(last, answer);
+ else visible.push(answer);
+ }
+ }
+ return visible;
+};
+
+const requestHeaders = {
+ // Matches the headers the shell sends in production.
+ 'X-Requested-With': 'XMLHttpRequest'
+};
+
+// Zeppelin JSON endpoints wrap the payload as { status, message, body }.
+const unwrapBody = (value: unknown): unknown =>
+ typeof value === 'object' && value !== null && 'body' in value ? value.body
: value;
+
+/** Hands 401/405 to the host, which owns login redirects and logout. */
+export type AssistantAuthErrorHandler = (status: number) => void;
+
+const httpError = (response: Response, onAuthError?:
AssistantAuthErrorHandler): AssistantHttpError => {
+ const error = new AssistantHttpError(response.status);
+ if (error.status === 401 || error.status === 405) {
+ onAuthError?.(error.status);
+ }
+ return error;
+};
+
+const requestJson = async <T>(
+ url: string,
+ init: { method?: string; body?: unknown } = {},
+ onAuthError?: AssistantAuthErrorHandler
+): Promise<T> => {
+ const response = await fetch(url, {
+ method: init.method ?? 'GET',
+ credentials: 'include',
+ headers:
+ init.body === undefined
+ ? { ...requestHeaders, Accept: 'application/json' }
+ : { ...requestHeaders, Accept: 'application/json', 'Content-Type':
'application/json' },
+ body: init.body === undefined ? undefined : JSON.stringify(init.body)
+ });
+ if (!response.ok) {
+ throw httpError(response, onAuthError);
+ }
+ const text = await response.text();
+ return (text ? unwrapBody(JSON.parse(text)) : undefined) as T;
+};
+
+// A server without the assistant API answers the note's conversation
collection with 404.
+const collectionRequest = async <T>(...args: Parameters<typeof requestJson>):
Promise<T> => {
+ try {
+ return await requestJson<T>(...args);
+ } catch (error) {
+ if (error instanceof AssistantHttpError && error.status === 404) {
+ throw new AssistantHttpError(404, NOT_AVAILABLE_MESSAGE);
+ }
+ throw error;
+ }
+};
+
+/**
+ * The one request a run follows. The server allows one run per conversation
and rejects a duplicate with 409,
+ * so the client keeps no state beyond the run it is reading; events of any
other conversation are dropped.
+ */
+interface RunSlot {
+ conversationId: string;
+ phase: 'pending' | 'running';
+ runId?: string;
+}
+
+/**
+ * Sends one message over the host's notebook WebSocket and yields that
conversation's events until a terminal one.
+ * Until this message's run.started arrives, only a rejection (run.failed)
belongs to it.
+ * Aborting stops local event consumption; it does not cancel a run already
accepted by the server.
+ */
+export async function* socketRun(
+ socket: AssistantSocket,
+ message: AssistantSendMessage,
+ signal: AbortSignal,
+ idleTimeoutMs = RUN_IDLE_TIMEOUT_MS
+): AsyncIterable<AssistantRunEvent> {
+ signal.throwIfAborted();
+ const slot: RunSlot = { conversationId: message.conversationId, phase:
'pending' };
+ const queue: Array<AssistantRunEvent | Error> = [];
+ let wake: (() => void) | undefined;
+ const push = (item: AssistantRunEvent | Error) => {
+ queue.push(item);
+ wake?.();
+ };
+ const unsubscribe = socket.subscribe(event => {
+ if (event.conversationId !== message.conversationId) return;
+ try {
+ const mapped = mapSocketEvent(event);
+ if (mapped) push(mapped);
+ } catch (error) {
+ push(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+ const unsubscribeClose = socket.subscribeClose(() => push(new
AssistantConnectionError()));
+ const onAbort = () => wake?.();
+ signal.addEventListener('abort', onAbort, { once: true });
+ try {
+ socket.send(message);
+ while (true) {
+ signal.throwIfAborted();
+ if (queue.length === 0) {
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ await new Promise<void>(resolve => {
+ wake = resolve;
+ timer = setTimeout(() => push(new AssistantStreamError()),
idleTimeoutMs);
+ });
+ clearTimeout(timer);
+ wake = undefined;
+ }
+ signal.throwIfAborted();
+ const item = queue.shift();
+ if (item === undefined) continue;
+ if (item instanceof Error) throw item;
+ const runId = 'runId' in item ? item.runId : undefined;
+ // Once running, only its own terminal event ends it; deltas have no run
id, but runs are one at a time.
+ if (slot.phase === 'running' && isTerminal(item) && runId && slot.runId
&& runId !== slot.runId) continue;
+ if (slot.phase === 'pending') {
+ if (item.type === 'run.started') {
+ slot.phase = 'running';
+ slot.runId = item.runId;
+ } else if (item.type !== 'run.failed') continue;
+ }
+ yield item;
+ if (isTerminal(item)) return;
+ }
+ } finally {
+ signal.removeEventListener('abort', onAbort);
+ unsubscribe();
+ unsubscribeClose();
+ }
+}
+
+type ConversationMetadataResponse = {
+ id: string;
+ noteId: string;
+ title?: string;
+ ownerId: string;
+ canSendMessage: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
+type MessagePage = { messages: ConversationMessage[]; cursor?: string | null };
+
+// One page of raw server messages; the panel loads earlier pages as the user
scrolls up.
+const HISTORY_PAGE_SIZE = 50;
+
+/**
+ * REST client for a note's conversations and history. `apiBase` is the host's
REST base, e.g. `.../api`.
+ */
+export const createAssistantConversationApi = (
+ apiBase: string,
+ noteId: string,
+ onAuthError?: AssistantAuthErrorHandler
+): AssistantConversationApi => {
+ const base = `${apiBase.replace(/\/$/,
'')}/notes/${encodeURIComponent(noteId)}/conversations`;
+ const toAssistantConversation = (conversation:
ConversationMetadataResponse): AssistantConversation => {
+ return {
+ id: conversation.id,
+ title: conversation.title,
+ ownerId: conversation.ownerId,
+ canSendMessage: conversation.canSendMessage,
+ updatedAt: conversation.updatedAt
+ };
+ };
+ return {
+ // Latest activity first, so the panel opens on it.
+ listConversations: async () =>
+ (await collectionRequest<ConversationMetadataResponse[]>(base, {},
onAuthError))
+ .map(conversation => ({
+ conversation,
+ time: Date.parse(conversation.updatedAt) || 0
+ }))
+ .sort((a, b) => b.time - a.time)
+ .map(({ conversation }) => toAssistantConversation(conversation)),
+ createConversation: async ({ title } = {}) =>
+ toAssistantConversation(
+ await collectionRequest<ConversationMetadataResponse>(
+ base,
+ { method: 'POST', body: title === undefined ? {} : { title } },
+ onAuthError
+ )
+ ),
+ deleteConversation: async conversationId => {
+ await requestJson<void>(`${base}/${encodeURIComponent(conversationId)}`,
{ method: 'DELETE' }, onAuthError);
+ },
+ getMessages: async (conversationId, before): Promise<AssistantMessagePage>
=> {
+ const cursor = before ? `&cursor=${encodeURIComponent(before)}` : '';
+ const page = await requestJson<MessagePage>(
+
`${base}/${encodeURIComponent(conversationId)}/messages?limit=${HISTORY_PAGE_SIZE}${cursor}`,
+ {},
+ onAuthError
+ );
+ // The server pages from the latest message backwards; the panel renders
oldest to newest.
+ return { messages: toVisibleMessages([...page.messages].reverse()),
cursor: page.cursor ?? null };
+ }
+ };
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts
b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts
new file mode 100644
index 0000000000..071950ac93
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type {
+ AssistantEvent as AssistantSocketEvent,
+ AssistantSendMessage
+} from './interfaces/message-assistant.interface';
+
+/** ASSISTANT_EVENT as the server sends it; the remote maps it to its run
events. */
+export type { AssistantSocketEvent };
+
+/** The host's notebook WebSocket, narrowed to the assistant ops. */
+export interface AssistantSocket {
+ send(message: AssistantSendMessage): void;
+ subscribe(listener: (event: AssistantSocketEvent) => void): () => void;
+ /** The connection closed. The server sends a run's events only to the
connection that started it. */
+ subscribeClose(listener: () => void): () => void;
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
b/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
index e7cab6800b..7ae587d381 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
@@ -11,6 +11,7 @@
*/
export * from './angular-object';
+export * from './assistant';
export * from './assistant-ui';
export * from './interfaces/public-api';
export * from './message';