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 e2792bdf48f..701e9dbfd20 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; createConversation(body: { title?: string }): Promise; deleteConversation(conversationId: string): Promise; /** The latest page of history, or the page before `before` (a page cursor). */ getMessages(conversationId: string, before?: string): Promise; - /** 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; } 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 00000000000..e1e763c3a94 --- /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 = (iterable: AsyncIterable): AsyncIterator => iterable[Symbol.asyncIterator](); + +const collect = async (events: AsyncIterable): Promise => { + 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 00000000000..ef6f0951a6b --- /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 = { + 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 = { + ...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 => typeof value === 'object' && value !== null; + +const optionalString = (data: Record, 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, 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 => { + 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 ( + url: string, + init: { method?: string; body?: unknown } = {}, + onAuthError?: AssistantAuthErrorHandler +): Promise => { + 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 (...args: Parameters): Promise => { + try { + return await requestJson(...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 { + signal.throwIfAborted(); + const slot: RunSlot = { conversationId: message.conversationId, phase: 'pending' }; + const queue: Array = []; + 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 | undefined; + await new Promise(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(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( + base, + { method: 'POST', body: title === undefined ? {} : { title } }, + onAuthError + ) + ), + deleteConversation: async conversationId => { + await requestJson(`${base}/${encodeURIComponent(conversationId)}`, { method: 'DELETE' }, onAuthError); + }, + getMessages: async (conversationId, before): Promise => { + const cursor = before ? `&cursor=${encodeURIComponent(before)}` : ''; + const page = await requestJson( + `${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 00000000000..071950ac939 --- /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 e7cab6800b9..7ae587d3811 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';