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';

Reply via email to