From c2ab3eb294566f3b1403730f675327474d6dd397 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Thu, 8 Oct 2026 16:25:52 +0900 Subject: [PATCH 01/16] Connect assistant REST and WebSocket transport --- .../model/assistantTransport.spec.ts | 800 ++++++++++++++++++ .../assistant/model/assistantTransport.ts | 595 +++++++++++++ .../projects/zeppelin-sdk/src/assistant.ts | 63 ++ .../projects/zeppelin-sdk/src/public-api.ts | 1 + 4 files changed, 1459 insertions(+) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.spec.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts 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..9859a76e14d --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.spec.ts @@ -0,0 +1,800 @@ +/* + * 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, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AssistantSendMessage, AssistantSocketEvent } from '@zeppelin/sdk'; + +import { + AssistantHttpError, + createAssistantTransport, + mapSocketEvent, + createRunTracking, + scopeTransport, + socketRun, + toVisibleMessages +} from './assistantTransport'; +import { + AssistantConnectionError, + AssistantStreamError, + type AssistantRunEvent, + type AssistantRunState +} from './assistantContract'; +import { 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, location: string | null) => void) => + createAssistantTransport('https://example.test/zeppelin/api', noteId, fakeSocket(), 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', () => { + // One note's sends share their run tracking, as the transport does. + let tracking = createRunTracking(); + beforeEach(() => { + tracking = createRunTracking(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('lists the most recently active conversation first and undated ones last', async () => { + const body = [ + { id: 'old', createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-01T00:00:00Z' }, + { id: 'undated' }, + { id: 'new', createdAt: '2026-09-30T00:00:00Z', updatedAt: '2026-10-03T00:00:00Z' }, + { id: 'created-only', createdAt: '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', 'created-only', 'old', 'undated']); + // The list shows the latest activity, or the creation time before any. + expect(conversations.map(conversation => conversation.updatedAt)).toEqual([ + '2026-10-03T00:00:00Z', + '2026-10-02T00:00:00Z', + '2026-10-01T00:00:00Z', + undefined + ]); + }); + + it('uses the per-note conversation endpoints and unwraps Zeppelin JSON responses', async () => { + const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + json({ + status: 'OK', + body: [{ id: 'conv-1', title: 'First', noteId: 'note/one', ownerId: 'alice', canSendMessage: false }] + }) + ) + .mockResolvedValueOnce(json({ status: 'OK', body: { id: 'conv-2', title: 'Load the data', ownerId: 'bob' } })) + .mockResolvedValueOnce( + json({ + status: 'OK', + body: { + messages: [ + { id: 'message-2', role: 'assistant', content: 'hi there' }, + { id: 'message-1', role: 'user', content: 'hello' } + ], + cursor: null + } + }) + ) + .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 } + ]); + await expect(transport.createConversation({ title: ' Load the data ' })).resolves.toEqual({ + id: 'conv-2', + title: 'Load the data', + ownerId: 'bob' + }); + // 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', content: 'hi there' } + ], + earlierCursor: 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' }), + // The first question names the conversation, with whitespace collapsed. + body: JSON.stringify({ title: 'Load the data' }) + }); + }); + + 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, headers: { Location: '/login' } })) + .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, location: '/login' }); + await expect(transport.createConversation({})).rejects.toMatchObject({ status: 405 }); + await expect(transport.getMessages('c')).rejects.toBeInstanceOf(AssistantHttpError); + + expect(onAuthError.mock.calls).toEqual([ + [401, '/login'], + [405, null] + ]); + }); + + it('sends over the socket and yields only this conversation until a terminal event', async () => { + const socket = fakeSocket(); + const transport = createAssistantTransport('https://example.test/api', 'n', socket); + const run = iterate(transport.openRun('c1', { prompt: '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', name: undefined }, + { 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, tracking)); + 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('blocks resending an aborted request until its delayed start is tracked', async () => { + const socket = fakeSocket(); + const leave = new AbortController(); + const first = iterate(socketRun(socket, message('first'), leave.signal, tracking)); + const response = first.next(); + leave.abort(); + await expect(response).rejects.toMatchObject({ name: 'AbortError' }); + await expect( + collect(socketRun(socket, message('second'), new AbortController().signal, tracking)) + ).resolves.toEqual([ + { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' } + ]); + expect(socket.sent).toHaveLength(1); + socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'old' } }); + expect(socket.listenerCount()).toBe(1); + expect(socket.closeListenerCount()).toBe(1); + const second = collect(socketRun(socket, message('second'), new AbortController().signal, tracking)); + socket.emit({ + conversationId: 'c1', + type: 'message.delta', + payload: { messageId: 'old-answer', delta: 'old answer' } + }); + socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'old' } }); + socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 409 } } }); + await expect(second).resolves.toEqual([ + { type: 'run.failed', runId: 'new', message: 'This conversation is still answering. Try again when it finishes.' } + ]); + }); + + it('tracks a queued start even when the consumer aborts before reading it', async () => { + const socket = fakeSocket(); + const controller = new AbortController(); + const first = iterate(socketRun(socket, message(), controller.signal, tracking)); + const response = first.next(); + socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'queued' } }); + controller.abort(); + await expect(response).rejects.toMatchObject({ name: 'AbortError' }); + expect(tracking.abandoned.has('queued')).toBe(true); + expect(socket.listenerCount()).toBe(1); + socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'queued' } }); + expect(socket.listenerCount()).toBe(0); + }); + + it('shares pending requests across transports on the same host connection', async () => { + const connectionKey = {}; + const socket = { ...fakeSocket(), connectionKey }; + const old = createAssistantTransport('/api', 'note', socket); + const leave = new AbortController(); + const response = iterate(old.openRun('c1', { prompt: 'first' }, leave.signal)).next(); + leave.abort(); + await expect(response).rejects.toMatchObject({ name: 'AbortError' }); + const next = createAssistantTransport('/api', 'note', { ...socket }); + await expect(collect(next.openRun('c1', { prompt: 'second' }, new AbortController().signal))).resolves.toEqual([ + { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' } + ]); + expect(socket.sent).toHaveLength(1); + socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'old', error: { status: 403 } } }); + expect(socket.listenerCount()).toBe(0); + const sent = collect(next.openRun('c1', { prompt: 'second' }, new AbortController().signal)); + socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 409 } } }); + await expect(sent).resolves.toHaveLength(1); + expect(socket.sent).toHaveLength(2); + }); + + it('releases unconfirmed sends when the connection closes', async () => { + const socket = fakeSocket(); + const controller = new AbortController(); + const response = iterate(socketRun(socket, message(), controller.signal, tracking)).next(); + controller.abort(); + await expect(response).rejects.toMatchObject({ name: 'AbortError' }); + socket.close(); + expect(socket.listenerCount()).toBe(0); + expect(socket.closeListenerCount()).toBe(0); + const reconnect = collect(socketRun(socket, message(), new AbortController().signal, tracking)); + socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 403 } } }); + await expect(reconnect).resolves.toHaveLength(1); + }); + + it('ignores the rest of an abandoned run when the next message is sent', async () => { + const socket = fakeSocket(); + const leave = new AbortController(); + const abandoned = iterate(socketRun(socket, message('first'), leave.signal, tracking)); + const firstEvent = abandoned.next(); + socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'run_old' } }); + expect((await firstEvent).value).toEqual({ type: 'run.started', runId: 'run_old' }); + leave.abort(); + await expect(abandoned.next()).rejects.toThrow(); + + // Sent while the abandoned run still streams: the server rejects it, and the old run's tail is not this run's. + const rejected = collect(socketRun(socket, message('second'), new AbortController().signal, tracking)); + socket.emit({ conversationId: 'c1', type: 'message.delta', payload: { messageId: 'old', delta: 'tail' } }); + socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'run_old' } }); + socket.emit({ + conversationId: 'c1', + type: 'run.failed', + payload: { runId: 'run_new', error: { status: 409 } } + }); + await expect(rejected).resolves.toEqual([ + { + type: 'run.failed', + runId: 'run_new', + message: 'This conversation is still answering. Try again when it finishes.' + } + ]); + + // Once the old run ended, the next message runs normally. + const next = collect(socketRun(socket, message('third'), new AbortController().signal, tracking)); + socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'run_3' } }); + socket.emit({ conversationId: 'c1', type: 'message.delta', payload: { messageId: 'm3', delta: 'ok' } }); + socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'run_3' } }); + await expect(next).resolves.toEqual([ + { type: 'run.started', runId: 'run_3' }, + { type: 'message.delta', messageId: 'm3', delta: 'ok' }, + { type: 'run.completed', runId: 'run_3' } + ]); + }); + + 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', content: 'part one.' }], earlierCursor: 'm3' }); + expect( + joinPages(page.messages, [ + { id: 'm5', role: 'assistant', content: 'part two.' }, + { id: 'm6', role: 'user', content: 'next' } + ]) + ).toEqual([ + { id: 'm5', role: 'assistant', content: 'part one.\n\npart two.' }, + { id: 'm6', role: 'user', content: 'next' } + ]); + }); + + it('explains a missing assistant configuration instead of the bare status', () => { + expect(new AssistantHttpError(503, null).message).toBe( + 'The assistant is not configured on this server. Ask an administrator to enable it.' + ); + expect(new AssistantHttpError(500, null).message).toBe('Assistant request failed with HTTP 500'); + }); + + it('keeps only the ACK listener when aborted before the run starts', async () => { + const socket = fakeSocket(); + const controller = new AbortController(); + const run = iterate(socketRun(socket, message(), controller.signal, tracking)); + const pending = run.next(); + await vi.waitFor(() => expect(socket.listenerCount()).toBe(2)); + + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(socket.listenerCount()).toBe(1); + socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'late' } }); + expect(socket.listenerCount()).toBe(1); + socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'late' } }); + expect(socket.listenerCount()).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, tracking)); + 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 when the run goes quiet past the idle timeout', async () => { + vi.useFakeTimers(); + const socket = fakeSocket(); + const pending = collect(socketRun(socket, message(), new AbortController().signal, tracking, 1000)); + const outcome = pending.then( + () => null, + (error: unknown) => error + ); + await vi.advanceTimersByTimeAsync(1000); + expect(await outcome).toBeInstanceOf(AssistantStreamError); + expect(socket.listenerCount()).toBe(1); + socket.close(); + expect(socket.listenerCount()).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, tracking)); + 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('drops late responses and aborts runs once a scoped transport is deactivated', async () => { + let resolveList: (conversations: []) => void = () => undefined; + let runSignal: AbortSignal | undefined; + const inner = { + listConversations: vi.fn(() => new Promise<[]>(resolve => (resolveList = resolve))), + createConversation: vi.fn(), + deleteConversation: vi.fn(), + getMessages: vi.fn(), + openRun: async function* (_id: string, _body: unknown, signal: AbortSignal): AsyncIterable { + runSignal = signal; + yield { type: 'run.started' }; + await new Promise(() => undefined); + } + }; + const scoped = scopeTransport(inner); + const list = scoped.transport.listConversations(); + const run = iterate(scoped.transport.openRun('t', { prompt: 'hi' }, new AbortController().signal)); + await run.next(); + expect(inner.listConversations).toHaveBeenCalledWith(); + + scoped.setActive(false); + resolveList([]); + + await expect(list).rejects.toMatchObject({ name: 'AbortError' }); + expect(runSignal?.aborted).toBe(true); + await expect(scoped.transport.getMessages('t')).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('retires pending creations and active runs when the host session aborts', async () => { + const session = new AbortController(); + let resolveCreate: (value: { id: string }) => void = () => undefined; + let runSignal: AbortSignal | undefined; + const inner = { + listConversations: vi.fn().mockResolvedValue([]), + createConversation: vi.fn( + () => + new Promise<{ id: string }>(resolve => { + resolveCreate = resolve; + }) + ), + deleteConversation: vi.fn(), + getMessages: vi.fn(), + openRun: async function* (_id: string, _body: unknown, signal: AbortSignal): AsyncIterable { + runSignal = signal; + yield { type: 'run.started' }; + await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true })); + yield { type: 'run.completed' }; + } + }; + const scoped = scopeTransport(inner, session.signal); + scoped.setActive(false); + scoped.setActive(true); + const creation = scoped.transport.createConversation({}); + const run = iterate(scoped.transport.openRun('c1', { prompt: 'hi' }, new AbortController().signal)); + await run.next(); + const terminal = run.next(); + session.abort(); + resolveCreate({ id: 'old-account-conversation' }); + await expect(creation).rejects.toMatchObject({ name: 'AbortError' }); + await expect(terminal).rejects.toMatchObject({ name: 'AbortError' }); + expect(runSignal?.aborted).toBe(true); + expect(scoped.active).toBe(false); + scoped.setActive(true); + expect(scoped.active).toBe(false); + await expect(scoped.transport.listConversations()).rejects.toMatchObject({ name: 'AbortError' }); + expect(inner.listConversations).not.toHaveBeenCalled(); + }); + + 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', content: 'Reading.\n\nDone.' } + ]); + }); + + 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', + content: 'Two paragraphs.', + toolCalls: [{ id: 'c1', name: 'list_paragraphs' }] + }); + expect(visible[3]).not.toHaveProperty('toolCalls'); + }); + + 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', 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, + { ...answer, toolCalls: call.toolCalls } + ]); + 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]); + expect(toVisibleMessages([question, call, ...nextTurn])).toEqual([...earlier, ...nextTurn]); + }); + + 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', content: 'Looking.', 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', content: 'Looking.\n\nDone.', toolCalls: [call] } + ]); + }); +}); + +describe('retired run subscriptions', () => { + it('does not subscribe or throw for an inactive or aborted session', () => { + const inner = createAssistantTransport('/api', 'note', fakeSocket()); + const subscribe = vi.spyOn(inner, 'subscribeRunState'); + const signal = new AbortController(); + const scoped = scopeTransport(inner, signal.signal); + signal.abort(); + expect(() => scoped.transport.subscribeRunState!('conversation', vi.fn())()).not.toThrow(); + expect(subscribe).not.toHaveBeenCalled(); + const retired = scopeTransport(inner); + retired.setActive(false); + expect(() => retired.transport.subscribeRunState!('conversation', vi.fn())()).not.toThrow(); + expect(subscribe).not.toHaveBeenCalled(); + }); +}); + +describe('retained run state', () => { + it.each([true, false])( + 'keeps old completion separate from a new rejection (completion first: %s)', + async completionFirst => { + const socket = fakeSocket(); + const transport = createAssistantTransport('/api', 'note', socket); + const controller = new AbortController(); + const first = iterate(transport.openRun('conversation', { prompt: 'old' }, controller.signal)); + const started = first.next(); + socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'old' } }); + await started; + controller.abort(); + await expect(first.next()).rejects.toThrow(); + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', running => states.push(running)); + const next = collect(transport.openRun('conversation', { prompt: 'new' }, new AbortController().signal)); + const completion = () => + socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'old' } }); + const rejection = () => + socket.emit({ + conversationId: 'conversation', + type: 'run.failed', + payload: { runId: 'new', error: { status: 429 } } + }); + if (completionFirst) { + completion(); + rejection(); + } else { + rejection(); + completion(); + } + expect(await next).toEqual([ + { type: 'run.failed', runId: 'new', message: 'The assistant is busy. Try again shortly.' } + ]); + expect(states[0]).toBe('running'); + expect(states[states.length - 1]).toBe('idle'); + expect(socket.listenerCount()).toBe(0); + stop(); + } + ); + + it('uses the server conversation response without a running field', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ body: [{ id: 'conversation', title: 'Question' }] }))); + vi.stubGlobal('fetch', fetch); + const transport = createAssistantTransport('/api', 'note', fakeSocket()); + expect(await transport.listConversations()).toEqual([{ id: 'conversation', title: 'Question' }]); + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', state => states.push(state)); + expect(states).toEqual(['idle']); + expect(fetch).toHaveBeenCalledTimes(1); + stop(); + }); + + it('keeps a disconnected run visible until history reload succeeds', async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new TypeError('Offline')) + .mockResolvedValueOnce(new Response(JSON.stringify({ body: { messages: [], cursor: null } }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + body: { messages: [{ id: 'answer', role: 'assistant', content: 'Recovered answer' }], cursor: null } + }) + ) + ); + vi.stubGlobal('fetch', fetch); + const socket = fakeSocket(); + const transport = createAssistantTransport('/api', 'note', socket); + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', state => states.push(state)); + const iterator = iterate(transport.openRun('conversation', { prompt: 'question' }, new AbortController().signal)); + const first = iterator.next(); + socket.close(); + await expect(first).rejects.toBeInstanceOf(AssistantConnectionError); + expect(states[states.length - 1]).toBe('disconnected'); + await expect(transport.getMessages('conversation')).rejects.toThrow('Offline'); + expect(states[states.length - 1]).toBe('disconnected'); + await transport.getMessages('conversation', 'older-page'); + expect(states[states.length - 1]).toBe('disconnected'); + await expect(transport.getMessages('conversation')).resolves.toEqual({ + messages: [{ id: 'answer', role: 'assistant', content: 'Recovered answer' }], + earlierCursor: null + }); + expect(states[states.length - 1]).toBe('idle'); + expect(fetch).toHaveBeenCalledTimes(3); + stop(); + }); + + it('clears a retained run on disconnect and releases listeners', async () => { + const socket = fakeSocket(); + const transport = createAssistantTransport('/api', 'note', socket); + const controller = new AbortController(); + const iterator = iterate(transport.openRun('conversation', { prompt: 'question' }, controller.signal)); + const first = iterator.next(); + socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); + await first; + controller.abort(); + await expect(iterator.next()).rejects.toThrow(); + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', running => states.push(running)); + socket.close(); + expect(states).toEqual(['running', 'disconnected']); + expect(socket.listenerCount()).toBe(0); + expect(socket.closeListenerCount()).toBe(0); + stop(); + }); + + it('follows an abandoned accepted run until completion and releases socket listeners', async () => { + const socket = fakeSocket(); + const transport = createAssistantTransport('/api', 'note', socket); + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', running => states.push(running)); + const controller = new AbortController(); + const iterator = iterate(transport.openRun('conversation', { prompt: 'question' }, controller.signal)); + const first = iterator.next(); + socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); + expect((await first).value).toEqual({ type: 'run.started', runId: 'run' }); + controller.abort(); + await expect(iterator.next()).rejects.toThrow(); + const reopened: AssistantRunState[] = []; + const stopReopened = transport.subscribeRunState!('conversation', running => reopened.push(running)); + expect(reopened).toEqual(['running']); + socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'run' } }); + expect(reopened).toEqual(['running', 'idle']); + expect(states).toContain('running'); + expect(socket.listenerCount()).toBe(0); + expect(socket.closeListenerCount()).toBe(0); + stop(); + stopReopened(); + }); +}); 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..4e059f9e3cd --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantTransport.ts @@ -0,0 +1,595 @@ +/* + * 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 AssistantMessage, + type AssistantMessagePage, + type AssistantRunEvent, + type AssistantRunState, + type AssistantToolCallRef, + type AssistantTransport +} 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, + public readonly location: string | null, + 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'), name: optionalString(data, 'name') }; + 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', + 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 = { + // As the shell does in production, so the server answers 401/405 instead of a login page. + '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, location: string | null) => void; + +const httpError = (response: Response, onAuthError?: AssistantAuthErrorHandler): AssistantHttpError => { + const error = new AssistantHttpError(response.status, response.headers.get('Location')); + if (error.status === 401 || error.status === 405) { + onAuthError?.(error.status, error.location); + } + return error; +}; + +const requestJson = async ( + url: string, + init: { method?: string; body?: unknown; signal?: AbortSignal } = {}, + onAuthError?: AssistantAuthErrorHandler +): Promise => { + const response = await fetch(url, { + method: init.method ?? 'GET', + ...(init.signal ? { signal: init.signal } : {}), + 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, error.location, NOT_AVAILABLE_MESSAGE); + } + throw error; + } +}; + +const MAX_ABANDONED_RUNS = 200; +/** + * Runs on one host connection. Pending sends survive note switches until acknowledged; abandoned run ids keep + * later sends from following their events. The server continues runs after the UI stops listening. + */ +interface PendingRun { + abandoned: boolean; + runId?: string; +} +export interface RunTracking { + abandoned: Set; + pending: Map; + active: Map; + disconnected: Set; + listeners: Map void>>; + stopTracking?: () => void; +} +export const createRunTracking = (): RunTracking => ({ + abandoned: new Set(), + pending: new Map(), + active: new Map(), + disconnected: new Set(), + listeners: new Map() +}); +const runState = (tracking: RunTracking, conversationId: string): AssistantRunState => + tracking.disconnected.has(conversationId) + ? 'disconnected' + : tracking.pending.has(conversationId) || tracking.active.has(conversationId) + ? 'running' + : 'idle'; +const notifyRunState = (tracking: RunTracking, conversationId: string) => { + tracking.listeners.get(conversationId)?.forEach(listener => listener(runState(tracking, conversationId))); +}; +const releaseTracking = (tracking: RunTracking) => { + if (!tracking.pending.size && !tracking.active.size) tracking.stopTracking?.(); +}; +const connectionRuns = new WeakMap(); +const runsFor = (socket: AssistantSocket): RunTracking => { + const key = socket.connectionKey ?? socket; + let runs = connectionRuns.get(key); + if (!runs) connectionRuns.set(key, (runs = createRunTracking())); + return runs; +}; +const abandon = (tracking: RunTracking, runId: string) => { + tracking.abandoned.add(runId); + if (tracking.abandoned.size > MAX_ABANDONED_RUNS) { + const oldest = tracking.abandoned.values().next().value; + if (oldest !== undefined) tracking.abandoned.delete(oldest); + } +}; + +// ACKs belong to the connection, even if the note or conversation that sent the request has unmounted. +const trackPendingRun = (socket: AssistantSocket, conversationId: string, tracking: RunTracking): PendingRun => { + const pending: PendingRun = { abandoned: false }; + tracking.disconnected.delete(conversationId); + tracking.pending.set(conversationId, pending); + notifyRunState(tracking, conversationId); + if (!tracking.stopTracking) { + const unsubscribe = socket.subscribe(event => { + const request = tracking.pending.get(event.conversationId); + const payload = isRecord(event.payload) ? event.payload : {}; + const runId = typeof payload.runId === 'string' ? payload.runId : undefined; + if (event.type === 'run.started' && runId && request) { + request.runId = runId; + tracking.active.set(event.conversationId, runId); + if (request.abandoned) abandon(tracking, runId); + tracking.pending.delete(event.conversationId); + } else if (event.type === 'run.failed' || event.type === 'run.completed') { + const activeRun = tracking.active.get(event.conversationId); + if (activeRun && (!runId || runId === activeRun)) { + tracking.active.delete(event.conversationId); + } else if (request && !(runId && tracking.abandoned.has(runId))) { + tracking.pending.delete(event.conversationId); + } else return; + } else return; + notifyRunState(tracking, event.conversationId); + releaseTracking(tracking); + }); + const unsubscribeClose = socket.subscribeClose(() => { + const conversations = new Set([...Array.from(tracking.pending.keys()), ...Array.from(tracking.active.keys())]); + tracking.pending.clear(); + tracking.active.clear(); + tracking.abandoned.clear(); + conversations.forEach(id => tracking.disconnected.add(id)); + conversations.forEach(id => notifyRunState(tracking, id)); + tracking.stopTracking?.(); + }); + tracking.stopTracking = () => { + unsubscribe(); + unsubscribeClose(); + tracking.stopTracking = undefined; + }; + } + return pending; +}; + +/** + * 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; events of an abandoned run + * on the same conversation are skipped. + */ +export async function* socketRun( + socket: AssistantSocket, + message: AssistantSendMessage, + signal: AbortSignal, + tracking: RunTracking, + idleTimeoutMs = RUN_IDLE_TIMEOUT_MS +): AsyncIterable { + signal.throwIfAborted(); + const { abandoned } = tracking; + if (tracking.pending.has(message.conversationId)) { + yield { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' }; + return; + } + const pending = trackPendingRun(socket, message.conversationId, tracking); + let sent = false; + 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 = () => { + pending.abandoned = true; + if (pending.runId) abandon(tracking, pending.runId); + wake?.(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + let started = false; + let ownRunId: string | undefined; + let ended = false; + try { + socket.send(message); + sent = true; + 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; + if (runId && runId !== ownRunId && abandoned.has(runId)) { + if (isTerminal(item)) abandoned.delete(runId); + continue; + } + // Once running, only its own terminal event ends it; deltas have no run id, but runs are one at a time. + if (started && isTerminal(item) && runId && ownRunId && runId !== ownRunId) continue; + if (!started) { + if (item.type === 'run.started') { + started = true; + ownRunId = item.runId; + } else if (item.type !== 'run.failed') continue; + } + if (isTerminal(item)) ended = true; + yield item; + if (ended) return; + } + } finally { + if (!ended && sent) { + pending.abandoned = true; + const runId = pending.runId ?? ownRunId; + if (runId) abandon(tracking, runId); + } + if (!sent || ended) { + if (tracking.pending.get(message.conversationId) === pending) tracking.pending.delete(message.conversationId); + notifyRunState(tracking, message.conversationId); + releaseTracking(tracking); + } + signal.removeEventListener('abort', onAbort); + unsubscribe(); + unsubscribeClose(); + } +} + +type ConversationSummary = { + id: 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; + +/** + * Transport for the per-note conversation API. REST for conversations and history (`apiBase` is the host's + * REST base, e.g. `.../api`); sending goes over the host's notebook WebSocket. + */ +export const createAssistantTransport = ( + apiBase: string, + noteId: string, + socket: AssistantSocket, + onAuthError?: AssistantAuthErrorHandler +): AssistantTransport => { + const base = `${apiBase.replace(/\/$/, '')}/notes/${encodeURIComponent(noteId)}/conversations`; + const runs = runsFor(socket); + const toConversation = (conversation: ConversationSummary): AssistantConversation => { + return { + id: conversation.id, + title: conversation.title, + ...(conversation.ownerId !== undefined ? { ownerId: conversation.ownerId } : {}), + ...(typeof conversation.canSendMessage === 'boolean' ? { canSendMessage: conversation.canSendMessage } : {}), + ...((conversation.updatedAt ?? conversation.createdAt) + ? { updatedAt: conversation.updatedAt ?? conversation.createdAt } + : {}) + }; + }; + // The server names an untitled conversation by its creation time. + const titleFor = (title?: string) => { + const trimmed = title?.trim().replace(/\s+/g, ' '); + return trimmed ? { title: trimmed.length > 80 ? `${trimmed.slice(0, 79)}…` : trimmed } : {}; + }; + return { + subscribeRunState: (conversationId, listener) => { + let listeners = runs.listeners.get(conversationId); + if (!listeners) runs.listeners.set(conversationId, (listeners = new Set())); + let stopped = false; + const observe = (state: AssistantRunState) => { + listener(state); + }; + listeners.add(observe); + observe(runState(runs, conversationId)); + const stop = () => { + if (stopped) return; + stopped = true; + listeners.delete(observe); + if (!listeners.size) runs.listeners.delete(conversationId); + socket.signal?.removeEventListener('abort', stop); + }; + socket.signal?.addEventListener('abort', stop, { once: true }); + if (socket.signal?.aborted) stop(); + return stop; + }, + // Latest activity first, so the panel opens on it; undated ones stay last in server order (stable sort). + listConversations: async () => + (await collectionRequest(base, {}, onAuthError)) + .map(conversation => ({ + conversation, + time: Date.parse(conversation.updatedAt ?? conversation.createdAt ?? '') || 0 + })) + .sort((a, b) => b.time - a.time) + .map(({ conversation }) => toConversation(conversation)), + createConversation: async ({ title } = {}) => + toConversation( + await collectionRequest(base, { method: 'POST', body: titleFor(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 + ); + if (!before && runs.disconnected.delete(conversationId)) notifyRunState(runs, conversationId); + // The server pages from the latest message backwards; the panel renders oldest to newest. + return { messages: toVisibleMessages([...page.messages].reverse()), earlierCursor: page.cursor ?? null }; + }, + openRun: (conversationId, body, signal) => + socketRun( + socket, + { + noteId, + conversationId, + content: body.prompt + }, + signal, + runs + ) + }; +}; + +/** + * Guards a per-note transport. After `setActive(false)` (note change or unmount) every pending or later call rejects + * with an AbortError and open runs are aborted, so a late response from the previous note cannot reach the new one. + */ +export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortSignal) => { + let active = true; + const runs = new Set(); + const subscriptions = new Set<() => void>(); + const assertActive = () => { + if (!active || sessionSignal?.aborted) { + throw new DOMException('Notebook changed', 'AbortError'); + } + }; + const guarded = async (request: () => Promise): Promise => { + assertActive(); + try { + const result = await request(); + assertActive(); + return result; + } catch (error) { + assertActive(); + throw error; + } + }; + const transport: AssistantTransport = { + listConversations: () => guarded(() => inner.listConversations()), + createConversation: body => guarded(() => inner.createConversation(body)), + deleteConversation: id => guarded(() => inner.deleteConversation(id)), + getMessages: (id, before) => guarded(() => inner.getMessages(id, before)), + ...(inner.subscribeRunState + ? { + subscribeRunState: (id: string, listener: (state: AssistantRunState) => void) => { + if (!active || sessionSignal?.aborted) return () => undefined; + const unsubscribe = inner.subscribeRunState!(id, state => { + if (active && !sessionSignal?.aborted) listener(state); + }); + const stop = () => { + if (!subscriptions.delete(stop)) return; + unsubscribe(); + sessionSignal?.removeEventListener('abort', stop); + }; + subscriptions.add(stop); + sessionSignal?.addEventListener('abort', stop, { once: true }); + return stop; + } + } + : {}), + async *openRun(id, body, signal) { + assertActive(); + const controller = new AbortController(); + const abort = () => controller.abort(); + signal.addEventListener('abort', abort, { once: true }); + sessionSignal?.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } + runs.add(controller); + try { + for await (const event of inner.openRun(id, body, controller.signal)) { + assertActive(); + if (controller.signal.aborted) { + throw new DOMException('Run stopped', 'AbortError'); + } + yield event; + } + } catch (error) { + assertActive(); + throw error; + } finally { + controller.abort(); + signal.removeEventListener('abort', abort); + sessionSignal?.removeEventListener('abort', abort); + runs.delete(controller); + } + } + }; + return { + get active() { + return active && !sessionSignal?.aborted; + }, + transport, + setActive(next: boolean) { + active = next; + if (!next) { + runs.forEach(controller => controller.abort()); + runs.clear(); + subscriptions.forEach(stop => stop()); + } + } + }; +}; 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..34a530ba5f3 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts @@ -0,0 +1,63 @@ +/* + * 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 { AssistantParagraphRef, AssistantRevealResult } from './assistant-ui'; + +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 { + /** Stable host connection identity across notebook adapters. */ + readonly connectionKey?: object; + /** Aborted when the host retires this notebook/account adapter, without closing the shared connection. */ + readonly signal?: AbortSignal; + 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; +} + +/** A host element the remote portals into: the sidebar button or the panel area. */ +export interface AssistantSlot { + element: HTMLElement; + kind: 'navigation' | 'panel'; +} + +/** Props the Angular notebook passes to the `./AssistantWorkspace` remote. A type alias, so it fits `ReactProps`. */ +export type AssistantHostProps = { + noteId: string; + /** The host's REST base (`.../api`); the remote calls the conversation REST API itself. */ + apiBase: string; + /** The host's notebook WebSocket, used to send messages and receive run events. */ + socket: AssistantSocket; + draftOwner?: string; + slots: AssistantSlot[]; + onAuthError?: (status: number, location: string | null) => void; + onError?: (error: unknown) => void; + /** Keeps the host sidebar to one open view at a time. */ + onPanelVisibilityChange?: (visible: boolean) => void; + subscribePanelClose?: (listener: () => void) => () => void; + /** Host-side scroll and highlight for a paragraph an answer links to; notebook DOM stays with Angular. */ + revealParagraph?: (paragraphId: string) => Promise; + /** The notebook's paragraphs in order, as data so labelling them never calls into the host. */ + paragraphs?: AssistantParagraphRef[]; + /** The notebook sidebar's width, which the panel shares so switching views keeps it. */ + panelWidth?: number; + /** A resize of the panel, at the end of a drag or per arrow key. */ + onPanelWidthChange?: (width: number) => 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'; From 583a3e497723df4c9b3ed48ea182a4d7402752b9 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 9 Oct 2026 19:14:58 +0900 Subject: [PATCH 02/16] Clarify Assistant transport reactivation comment --- .../src/entities/assistant/model/assistantTransport.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 4e059f9e3cd..9efae561f6c 100644 --- 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 @@ -503,8 +503,8 @@ export const createAssistantTransport = ( }; /** - * Guards a per-note transport. After `setActive(false)` (note change or unmount) every pending or later call rejects - * with an AbortError and open runs are aborted, so a late response from the previous note cannot reach the new one. + * Guards a per-note transport. Deactivation aborts open runs and rejects calls while inactive. + * The transport can be reactivated unless its session signal has aborted. */ export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortSignal) => { let active = true; From 8ae45d323fa1a9275ec6c81460805aa9a1614bb8 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 11 Oct 2026 13:26:07 +0900 Subject: [PATCH 03/16] Share Assistant slot type with the sidebar shell --- .../projects/zeppelin-sdk/src/assistant.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts index 34a530ba5f3..13324721ca1 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts @@ -10,7 +10,7 @@ * limitations under the License. */ -import type { AssistantParagraphRef, AssistantRevealResult } from './assistant-ui'; +import type { AssistantParagraphRef, AssistantRevealResult, AssistantSlot } from './assistant-ui'; import type { AssistantEvent as AssistantSocketEvent, @@ -32,12 +32,6 @@ export interface AssistantSocket { subscribeClose(listener: () => void): () => void; } -/** A host element the remote portals into: the sidebar button or the panel area. */ -export interface AssistantSlot { - element: HTMLElement; - kind: 'navigation' | 'panel'; -} - /** Props the Angular notebook passes to the `./AssistantWorkspace` remote. A type alias, so it fits `ReactProps`. */ export type AssistantHostProps = { noteId: string; From 1161409d6b265326c514d1a864fd39925d6410d0 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 11 Oct 2026 17:23:56 +0900 Subject: [PATCH 04/16] Preserve Assistant disconnect state on rejected retries --- .../model/assistantTransport.spec.ts | 38 +++++++++++++++++++ .../assistant/model/assistantTransport.ts | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) 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 index 9859a76e14d..edfa193500e 100644 --- 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 @@ -755,6 +755,44 @@ describe('retained run state', () => { stop(); }); + it('keeps a disconnected run visible when a retry is rejected', async () => { + const socket = fakeSocket(); + const transport = createAssistantTransport('/api', 'note', socket); + const first = collect(transport.openRun('conversation', { prompt: 'first' }, new AbortController().signal)); + socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'first' } }); + socket.close(); + await expect(first).rejects.toBeInstanceOf(AssistantConnectionError); + + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', state => states.push(state)); + const retry = collect(transport.openRun('conversation', { prompt: 'retry' }, new AbortController().signal)); + socket.emit({ + conversationId: 'conversation', + type: 'run.failed', + payload: { runId: 'retry', error: { status: 409 } } + }); + await expect(retry).resolves.toEqual([ + { + type: 'run.failed', + runId: 'retry', + message: 'This conversation is still answering. Try again when it finishes.' + } + ]); + expect(states).toContain('disconnected'); + expect(states).not.toContain('idle'); + + const accepted = collect(transport.openRun('conversation', { prompt: 'later' }, new AbortController().signal)); + socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'later' } }); + socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'later' } }); + await expect(accepted).resolves.toEqual([ + { type: 'run.started', runId: 'later' }, + { type: 'run.completed', runId: 'later' } + ]); + expect(states).toContain('running'); + expect(states.at(-1)).toBe('idle'); + stop(); + }); + it('clears a retained run on disconnect and releases listeners', async () => { const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); 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 index 9efae561f6c..4fffc2aa353 100644 --- 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 @@ -260,7 +260,6 @@ const abandon = (tracking: RunTracking, runId: string) => { // ACKs belong to the connection, even if the note or conversation that sent the request has unmounted. const trackPendingRun = (socket: AssistantSocket, conversationId: string, tracking: RunTracking): PendingRun => { const pending: PendingRun = { abandoned: false }; - tracking.disconnected.delete(conversationId); tracking.pending.set(conversationId, pending); notifyRunState(tracking, conversationId); if (!tracking.stopTracking) { @@ -271,6 +270,7 @@ const trackPendingRun = (socket: AssistantSocket, conversationId: string, tracki if (event.type === 'run.started' && runId && request) { request.runId = runId; tracking.active.set(event.conversationId, runId); + tracking.disconnected.delete(event.conversationId); if (request.abandoned) abandon(tracking, runId); tracking.pending.delete(event.conversationId); } else if (event.type === 'run.failed' || event.type === 'run.completed') { From 5740ccdc279f8d9001c72f5fc15c79be6a2cb357 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 11 Oct 2026 17:25:58 +0900 Subject: [PATCH 05/16] Ignore history responses older than an Assistant disconnect --- .../model/assistantTransport.spec.ts | 31 +++++++++++++++++++ .../assistant/model/assistantTransport.ts | 12 ++++--- 2 files changed, 39 insertions(+), 4 deletions(-) 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 index edfa193500e..a67bd4cdb97 100644 --- 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 @@ -793,6 +793,37 @@ describe('retained run state', () => { stop(); }); + it('does not recover a disconnect with history requested before it', async () => { + let resolveHistory: (response: Response) => void = () => undefined; + const delayedHistory = new Promise(resolve => { + resolveHistory = resolve; + }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockReturnValueOnce(delayedHistory) + .mockResolvedValueOnce(new Response(JSON.stringify({ body: { messages: [], cursor: null } }))) + ); + const socket = fakeSocket(); + const transport = createAssistantTransport('/api', 'note', socket); + const run = collect(transport.openRun('conversation', { prompt: 'question' }, new AbortController().signal)); + socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); + const staleHistory = transport.getMessages('conversation'); + socket.close(); + await expect(run).rejects.toBeInstanceOf(AssistantConnectionError); + const states: AssistantRunState[] = []; + const stop = transport.subscribeRunState!('conversation', state => states.push(state)); + + resolveHistory(new Response(JSON.stringify({ body: { messages: [], cursor: null } }))); + await staleHistory; + expect(states).toEqual(['disconnected']); + + await transport.getMessages('conversation'); + expect(states).toEqual(['disconnected', 'idle']); + stop(); + }); + it('clears a retained run on disconnect and releases listeners', async () => { const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); 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 index 4fffc2aa353..65211ac93cb 100644 --- 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 @@ -219,7 +219,7 @@ export interface RunTracking { abandoned: Set; pending: Map; active: Map; - disconnected: Set; + disconnected: Map; listeners: Map void>>; stopTracking?: () => void; } @@ -227,7 +227,7 @@ export const createRunTracking = (): RunTracking => ({ abandoned: new Set(), pending: new Map(), active: new Map(), - disconnected: new Set(), + disconnected: new Map(), listeners: new Map() }); const runState = (tracking: RunTracking, conversationId: string): AssistantRunState => @@ -289,7 +289,7 @@ const trackPendingRun = (socket: AssistantSocket, conversationId: string, tracki tracking.pending.clear(); tracking.active.clear(); tracking.abandoned.clear(); - conversations.forEach(id => tracking.disconnected.add(id)); + conversations.forEach(id => tracking.disconnected.set(id, {})); conversations.forEach(id => notifyRunState(tracking, id)); tracking.stopTracking?.(); }); @@ -479,12 +479,16 @@ export const createAssistantTransport = ( }, getMessages: async (conversationId, before): Promise => { const cursor = before ? `&cursor=${encodeURIComponent(before)}` : ''; + const disconnectedAtRequest = runs.disconnected.get(conversationId); const page = await requestJson( `${base}/${encodeURIComponent(conversationId)}/messages?limit=${HISTORY_PAGE_SIZE}${cursor}`, {}, onAuthError ); - if (!before && runs.disconnected.delete(conversationId)) notifyRunState(runs, conversationId); + if (!before && disconnectedAtRequest && runs.disconnected.get(conversationId) === disconnectedAtRequest) { + runs.disconnected.delete(conversationId); + notifyRunState(runs, conversationId); + } // The server pages from the latest message backwards; the panel renders oldest to newest. return { messages: toVisibleMessages([...page.messages].reverse()), earlierCursor: page.cursor ?? null }; }, From 52ce84236d4db8bbf65b854a619f85d3f9b86118 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Thu, 15 Oct 2026 23:34:37 +0900 Subject: [PATCH 06/16] Preserve Assistant rounds in conversation transport --- .../model/assistantTransport.spec.ts | 135 +++++++++++++++--- .../assistant/model/assistantTransport.ts | 5 +- 2 files changed, 119 insertions(+), 21 deletions(-) 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 index a67bd4cdb97..62ad5c3b2d3 100644 --- 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 @@ -110,8 +110,8 @@ describe('assistant transport', () => { }); it('uses the per-note conversation endpoints and unwraps Zeppelin JSON responses', async () => { - const json = (body: unknown) => - new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -120,7 +120,9 @@ describe('assistant transport', () => { body: [{ id: 'conv-1', title: 'First', noteId: 'note/one', ownerId: 'alice', canSendMessage: false }] }) ) - .mockResolvedValueOnce(json({ status: 'OK', body: { id: 'conv-2', title: 'Load the data', ownerId: 'bob' } })) + .mockResolvedValueOnce( + json({ status: 'CREATED', body: { id: 'conv-2', title: 'Load the data', ownerId: 'bob' } }, 201) + ) .mockResolvedValueOnce( json({ status: 'OK', @@ -128,8 +130,7 @@ describe('assistant transport', () => { messages: [ { id: 'message-2', role: 'assistant', content: 'hi there' }, { id: 'message-1', role: 'user', content: 'hello' } - ], - cursor: null + ] } }) ) @@ -149,7 +150,12 @@ describe('assistant transport', () => { await expect(transport.getMessages('conv/1')).resolves.toEqual({ messages: [ { id: 'message-1', role: 'user', content: 'hello' }, - { id: 'message-2', role: 'assistant', content: 'hi there' } + { + id: 'message-2', + role: 'assistant', + content: 'hi there', + segments: [{ id: 'message-2', content: 'hi there' }] + } ], earlierCursor: null }); @@ -235,7 +241,7 @@ describe('assistant transport', () => { expect(events).toEqual([ { type: 'run.started', runId: 'run_1' }, { type: 'tool_call.started', toolCallId: 't1', name: 'list_paragraphs' }, - { type: 'tool_call.done', toolCallId: 't1', name: undefined }, + { type: 'tool_call.done', toolCallId: 't1' }, { type: 'message.delta', messageId: 'm1', delta: '한글' }, { type: 'run.completed', runId: 'run_1' } ]); @@ -414,14 +420,25 @@ describe('assistant transport', () => { 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', content: 'part one.' }], earlierCursor: 'm3' }); + expect(page).toEqual({ + messages: [{ id: 'm3', role: 'assistant', content: 'part one.', segments: [{ id: 'm3', content: 'part one.' }] }], + earlierCursor: 'm3' + }); expect( joinPages(page.messages, [ - { id: 'm5', role: 'assistant', content: 'part two.' }, + { id: 'm5', role: 'assistant', content: 'part two.', segments: [{ id: 'm5', content: 'part two.' }] }, { id: 'm6', role: 'user', content: 'next' } ]) ).toEqual([ - { id: 'm5', role: 'assistant', content: 'part one.\n\npart two.' }, + { + id: 'm5', + role: 'assistant', + content: 'part one.\n\npart two.', + segments: [ + { id: 'm3', content: 'part one.' }, + { id: 'm5', content: 'part two.' } + ] + }, { id: 'm6', role: 'user', content: 'next' } ]); }); @@ -582,7 +599,15 @@ describe('assistant transport', () => { ).toEqual([ { id: 'u1', role: 'user', content: 'improve' }, // The merged bubble keeps the id of its last message. - { id: 'a3', role: 'assistant', content: 'Reading.\n\nDone.' } + { + id: 'a3', + role: 'assistant', + content: 'Reading.\n\nDone.', + segments: [ + { id: 'a2', content: 'Reading.' }, + { id: 'a3', content: 'Done.' } + ] + } ]); }); @@ -599,11 +624,34 @@ describe('assistant transport', () => { id: 'a2', role: 'assistant', content: 'Two paragraphs.', - toolCalls: [{ id: 'c1', name: 'list_paragraphs' }] + toolCalls: [{ id: 'c1', name: 'list_paragraphs' }], + segments: [ + { id: 'a1', content: '', toolCalls: [{ id: 'c1', name: 'list_paragraphs' }] }, + { id: 'a2', content: 'Two paragraphs.' } + ] }); expect(visible[3]).not.toHaveProperty('toolCalls'); }); + 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 = { @@ -615,21 +663,42 @@ describe('assistant transport', () => { 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', content: '', toolCalls: call.toolCalls }); + expect(earlier[1]).toEqual({ + id: 'a1', + role: 'assistant', + content: '', + toolCalls: call.toolCalls, + 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, - { ...answer, toolCalls: call.toolCalls } + { + ...answer, + toolCalls: call.toolCalls, + 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]); - expect(toVisibleMessages([question, call, ...nextTurn])).toEqual([...earlier, ...nextTurn]); + expect(joinPages(earlier, toVisibleMessages(nextTurn))).toEqual([ + ...earlier, + nextTurn[0], + { ...nextTurn[1], segments: [{ id: 'a3', content: 'Another answer' }] } + ]); + expect(toVisibleMessages([question, call, ...nextTurn])).toEqual([ + ...earlier, + nextTurn[0], + { ...nextTurn[1], segments: [{ id: 'a3', content: 'Another answer' }] } + ]); }); it('keeps a trailing tool round after answer text and deduplicates call ids at page boundaries', () => { @@ -638,13 +707,34 @@ describe('assistant transport', () => { { id: 'a1', role: 'assistant', content: 'Looking.' }, { id: 'a2', role: 'assistant', content: '', toolCalls: [call] } ]); - expect(earlier).toEqual([{ id: 'a2', role: 'assistant', content: 'Looking.', toolCalls: [call] }]); + expect(earlier).toEqual([ + { + id: 'a2', + role: 'assistant', + content: 'Looking.', + toolCalls: [call], + 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', content: 'Looking.\n\nDone.', toolCalls: [call] } + { + id: 'a3', + role: 'assistant', + content: 'Looking.\n\nDone.', + toolCalls: [call], + segments: [ + { id: 'a1', content: 'Looking.' }, + { id: 'a2', content: '', toolCalls: [call] }, + { id: 'a3', content: 'Done.' } + ] + } ]); }); }); @@ -747,7 +837,14 @@ describe('retained run state', () => { await transport.getMessages('conversation', 'older-page'); expect(states[states.length - 1]).toBe('disconnected'); await expect(transport.getMessages('conversation')).resolves.toEqual({ - messages: [{ id: 'answer', role: 'assistant', content: 'Recovered answer' }], + messages: [ + { + id: 'answer', + role: 'assistant', + content: 'Recovered answer', + segments: [{ id: 'answer', content: 'Recovered answer' }] + } + ], earlierCursor: null }); expect(states[states.length - 1]).toBe('idle'); 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 index 65211ac93cb..2703eb9ff3c 100644 --- 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 @@ -105,7 +105,7 @@ export const mapSocketEvent = ({ type, payload }: AssistantSocketEvent): Assista case 'tool_call.started': return { type, toolCallId: requiredString(data, 'toolCallId'), name: requiredString(data, 'name') }; case 'tool_call.done': - return { type, toolCallId: requiredString(data, 'toolCallId'), name: optionalString(data, 'name') }; + return { type, toolCallId: requiredString(data, 'toolCallId') }; default: return undefined; } @@ -143,7 +143,8 @@ export const toVisibleMessages = (messages: ConversationMessage[]): AssistantMes id: message.id, role: 'assistant', content: message.content ?? '', - ...(toolCalls.length ? { toolCalls } : {}) + ...(toolCalls.length ? { toolCalls } : {}), + 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); From 7b50b6e8251712fd8a063f89cadc6756bead62c4 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 00:21:20 +0900 Subject: [PATCH 07/16] Consume segment-only assistant history contract --- .../model/assistantTransport.spec.ts | 31 +++++++------------ .../assistant/model/assistantTransport.ts | 2 -- 2 files changed, 11 insertions(+), 22 deletions(-) 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 index 62ad5c3b2d3..e9b019f7a6a 100644 --- 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 @@ -26,10 +26,11 @@ import { import { AssistantConnectionError, AssistantStreamError, + type AssistantAnswerMessage, type AssistantRunEvent, type AssistantRunState } from './assistantContract'; -import { joinPages } from './messageHistory'; +import { answerText, answerToolCalls, joinPages } from './messageHistory'; /** A host socket double: records sends and lets a test emit server events. */ const fakeSocket = () => { @@ -153,7 +154,6 @@ describe('assistant transport', () => { { id: 'message-2', role: 'assistant', - content: 'hi there', segments: [{ id: 'message-2', content: 'hi there' }] } ], @@ -421,19 +421,18 @@ describe('assistant transport', () => { 'https://example.test/zeppelin/api/notes/n/conversations/c/messages?limit=50&cursor=m9' ); expect(page).toEqual({ - messages: [{ id: 'm3', role: 'assistant', content: 'part one.', segments: [{ id: 'm3', content: 'part one.' }] }], + messages: [{ id: 'm3', role: 'assistant', segments: [{ id: 'm3', content: 'part one.' }] }], earlierCursor: 'm3' }); expect( joinPages(page.messages, [ - { id: 'm5', role: 'assistant', content: 'part two.', segments: [{ id: 'm5', content: 'part two.' }] }, + { id: 'm5', role: 'assistant', segments: [{ id: 'm5', content: 'part two.' }] }, { id: 'm6', role: 'user', content: 'next' } ]) ).toEqual([ { id: 'm5', role: 'assistant', - content: 'part one.\n\npart two.', segments: [ { id: 'm3', content: 'part one.' }, { id: 'm5', content: 'part two.' } @@ -602,7 +601,6 @@ describe('assistant transport', () => { { id: 'a3', role: 'assistant', - content: 'Reading.\n\nDone.', segments: [ { id: 'a2', content: 'Reading.' }, { id: 'a3', content: 'Done.' } @@ -623,14 +621,14 @@ describe('assistant transport', () => { expect(visible[1]).toEqual({ id: 'a2', role: 'assistant', - content: 'Two paragraphs.', - toolCalls: [{ id: 'c1', name: 'list_paragraphs' }], segments: [ { id: 'a1', content: '', toolCalls: [{ id: 'c1', name: 'list_paragraphs' }] }, { id: 'a2', content: 'Two paragraphs.' } ] }); - expect(visible[3]).not.toHaveProperty('toolCalls'); + 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', () => { @@ -666,8 +664,6 @@ describe('assistant transport', () => { expect(earlier[1]).toEqual({ id: 'a1', role: 'assistant', - content: '', - toolCalls: call.toolCalls, segments: [{ id: 'a1', content: '', toolCalls: call.toolCalls }] }); expect(joinPages(earlier, toVisibleMessages([result, answer]))).toEqual( @@ -677,8 +673,8 @@ describe('assistant transport', () => { expect(joinPages(joinPages(earlier, toVisibleMessages([result])), toVisibleMessages([answer]))).toEqual([ question, { - ...answer, - toolCalls: call.toolCalls, + id: answer.id, + role: answer.role, segments: [ { id: 'a1', content: '', toolCalls: call.toolCalls }, { id: 'a2', content: 'Two paragraphs.' } @@ -692,12 +688,12 @@ describe('assistant transport', () => { expect(joinPages(earlier, toVisibleMessages(nextTurn))).toEqual([ ...earlier, nextTurn[0], - { ...nextTurn[1], segments: [{ id: 'a3', content: 'Another answer' }] } + { id: 'a3', role: 'assistant', segments: [{ id: 'a3', content: 'Another answer' }] } ]); expect(toVisibleMessages([question, call, ...nextTurn])).toEqual([ ...earlier, nextTurn[0], - { ...nextTurn[1], segments: [{ id: 'a3', content: 'Another answer' }] } + { id: 'a3', role: 'assistant', segments: [{ id: 'a3', content: 'Another answer' }] } ]); }); @@ -711,8 +707,6 @@ describe('assistant transport', () => { { id: 'a2', role: 'assistant', - content: 'Looking.', - toolCalls: [call], segments: [ { id: 'a1', content: 'Looking.' }, { id: 'a2', content: '', toolCalls: [call] } @@ -727,8 +721,6 @@ describe('assistant transport', () => { { id: 'a3', role: 'assistant', - content: 'Looking.\n\nDone.', - toolCalls: [call], segments: [ { id: 'a1', content: 'Looking.' }, { id: 'a2', content: '', toolCalls: [call] }, @@ -841,7 +833,6 @@ describe('retained run state', () => { { id: 'answer', role: 'assistant', - content: 'Recovered answer', segments: [{ id: 'answer', content: 'Recovered answer' }] } ], 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 index 2703eb9ff3c..efd62f34502 100644 --- 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 @@ -142,8 +142,6 @@ export const toVisibleMessages = (messages: ConversationMessage[]): AssistantMes const answer: AssistantMessage = { id: message.id, role: 'assistant', - content: message.content ?? '', - ...(toolCalls.length ? { toolCalls } : {}), segments: [{ id: message.id, content: message.content ?? '', ...(toolCalls.length ? { toolCalls } : {}) }] }; if (last?.role === 'assistant') visible[visible.length - 1] = mergeAnswers(last, answer); From 1fad6cd8105f1984f0df00e17869ccdc08eb56bb Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 00:39:14 +0900 Subject: [PATCH 08/16] Align Assistant transport with server contract --- .../model/assistantTransport.spec.ts | 108 ++++++++++++------ .../assistant/model/assistantTransport.ts | 23 ++-- 2 files changed, 80 insertions(+), 51 deletions(-) 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 index e9b019f7a6a..a64767cccfe 100644 --- 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 @@ -27,6 +27,7 @@ import { AssistantConnectionError, AssistantStreamError, type AssistantAnswerMessage, + type AssistantConversation, type AssistantRunEvent, type AssistantRunState } from './assistantContract'; @@ -83,12 +84,11 @@ describe('assistant transport', () => { vi.useRealTimers(); }); - it('lists the most recently active conversation first and undated ones last', async () => { + it('lists the most recently active conversation first', async () => { const body = [ - { id: 'old', createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-01T00:00:00Z' }, - { id: 'undated' }, - { id: 'new', createdAt: '2026-09-30T00:00:00Z', updatedAt: '2026-10-03T00:00:00Z' }, - { id: 'created-only', createdAt: '2026-10-02T00:00:00Z' } + { 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', @@ -100,14 +100,13 @@ describe('assistant transport', () => { ) ); const conversations = await forNote('note-1').listConversations(); - expect(conversations.map(conversation => conversation.id)).toEqual(['new', 'created-only', 'old', 'undated']); - // The list shows the latest activity, or the creation time before any. + 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', - undefined + '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 () => { @@ -118,11 +117,32 @@ describe('assistant transport', () => { .mockResolvedValueOnce( json({ status: 'OK', - body: [{ id: 'conv-1', title: 'First', noteId: 'note/one', ownerId: 'alice', canSendMessage: false }] + body: [ + { + id: 'conv-1', + title: 'First', + noteId: 'note/one', + ownerId: 'alice', + canSendMessage: false, + updatedAt: '2026-10-01T00:00:00Z' + } + ] }) ) .mockResolvedValueOnce( - json({ status: 'CREATED', body: { id: 'conv-2', title: 'Load the data', ownerId: 'bob' } }, 201) + json( + { + status: 'CREATED', + body: { + id: 'conv-2', + title: 'Load the data', + ownerId: 'bob', + canSendMessage: true, + updatedAt: '2026-10-02T00:00:00Z' + } + }, + 201 + ) ) .mockResolvedValueOnce( json({ @@ -140,12 +160,14 @@ describe('assistant transport', () => { const transport = forNote('note/one'); await expect(transport.listConversations()).resolves.toEqual([ - { id: 'conv-1', title: 'First', ownerId: 'alice', canSendMessage: false } + { 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' + 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({ @@ -157,7 +179,7 @@ describe('assistant transport', () => { segments: [{ id: 'message-2', content: 'hi there' }] } ], - earlierCursor: null + cursor: null }); await expect(transport.deleteConversation('conv/1')).resolves.toBeUndefined(); @@ -213,7 +235,7 @@ describe('assistant transport', () => { it('sends over the socket and yields only this conversation until a terminal event', async () => { const socket = fakeSocket(); const transport = createAssistantTransport('https://example.test/api', 'n', socket); - const run = iterate(transport.openRun('c1', { prompt: 'improve' }, new AbortController().signal)); + const run = iterate(transport.openRun('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' }); @@ -313,17 +335,17 @@ describe('assistant transport', () => { const socket = { ...fakeSocket(), connectionKey }; const old = createAssistantTransport('/api', 'note', socket); const leave = new AbortController(); - const response = iterate(old.openRun('c1', { prompt: 'first' }, leave.signal)).next(); + const response = iterate(old.openRun('c1', { content: 'first' }, leave.signal)).next(); leave.abort(); await expect(response).rejects.toMatchObject({ name: 'AbortError' }); const next = createAssistantTransport('/api', 'note', { ...socket }); - await expect(collect(next.openRun('c1', { prompt: 'second' }, new AbortController().signal))).resolves.toEqual([ + await expect(collect(next.openRun('c1', { content: 'second' }, new AbortController().signal))).resolves.toEqual([ { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' } ]); expect(socket.sent).toHaveLength(1); socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'old', error: { status: 403 } } }); expect(socket.listenerCount()).toBe(0); - const sent = collect(next.openRun('c1', { prompt: 'second' }, new AbortController().signal)); + const sent = collect(next.openRun('c1', { content: 'second' }, new AbortController().signal)); socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 409 } } }); await expect(sent).resolves.toHaveLength(1); expect(socket.sent).toHaveLength(2); @@ -422,7 +444,7 @@ describe('assistant transport', () => { ); expect(page).toEqual({ messages: [{ id: 'm3', role: 'assistant', segments: [{ id: 'm3', content: 'part one.' }] }], - earlierCursor: 'm3' + cursor: 'm3' }); expect( joinPages(page.messages, [ @@ -532,7 +554,7 @@ describe('assistant transport', () => { }; const scoped = scopeTransport(inner); const list = scoped.transport.listConversations(); - const run = iterate(scoped.transport.openRun('t', { prompt: 'hi' }, new AbortController().signal)); + const run = iterate(scoped.transport.openRun('t', { content: 'hi' }, new AbortController().signal)); await run.next(); expect(inner.listConversations).toHaveBeenCalledWith(); @@ -546,13 +568,13 @@ describe('assistant transport', () => { it('retires pending creations and active runs when the host session aborts', async () => { const session = new AbortController(); - let resolveCreate: (value: { id: string }) => void = () => undefined; + let resolveCreate: (value: AssistantConversation) => void = () => undefined; let runSignal: AbortSignal | undefined; const inner = { listConversations: vi.fn().mockResolvedValue([]), createConversation: vi.fn( () => - new Promise<{ id: string }>(resolve => { + new Promise(resolve => { resolveCreate = resolve; }) ), @@ -569,11 +591,16 @@ describe('assistant transport', () => { scoped.setActive(false); scoped.setActive(true); const creation = scoped.transport.createConversation({}); - const run = iterate(scoped.transport.openRun('c1', { prompt: 'hi' }, new AbortController().signal)); + const run = iterate(scoped.transport.openRun('c1', { content: 'hi' }, new AbortController().signal)); await run.next(); const terminal = run.next(); session.abort(); - resolveCreate({ id: 'old-account-conversation' }); + resolveCreate({ + id: 'old-account-conversation', + ownerId: 'alice', + canSendMessage: true, + updatedAt: '2026-10-01T00:00:00Z' + }); await expect(creation).rejects.toMatchObject({ name: 'AbortError' }); await expect(terminal).rejects.toMatchObject({ name: 'AbortError' }); expect(runSignal?.aborted).toBe(true); @@ -754,7 +781,7 @@ describe('retained run state', () => { const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); const controller = new AbortController(); - const first = iterate(transport.openRun('conversation', { prompt: 'old' }, controller.signal)); + const first = iterate(transport.openRun('conversation', { content: 'old' }, controller.signal)); const started = first.next(); socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'old' } }); await started; @@ -762,7 +789,7 @@ describe('retained run state', () => { await expect(first.next()).rejects.toThrow(); const states: AssistantRunState[] = []; const stop = transport.subscribeRunState!('conversation', running => states.push(running)); - const next = collect(transport.openRun('conversation', { prompt: 'new' }, new AbortController().signal)); + const next = collect(transport.openRun('conversation', { content: 'new' }, new AbortController().signal)); const completion = () => socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'old' } }); const rejection = () => @@ -789,12 +816,17 @@ describe('retained run state', () => { ); it('uses the server conversation response without a running field', async () => { - const fetch = vi - .fn() - .mockResolvedValue(new Response(JSON.stringify({ body: [{ id: 'conversation', title: 'Question' }] }))); + 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 = createAssistantTransport('/api', 'note', fakeSocket()); - expect(await transport.listConversations()).toEqual([{ id: 'conversation', title: 'Question' }]); + expect(await transport.listConversations()).toEqual([conversation]); const states: AssistantRunState[] = []; const stop = transport.subscribeRunState!('conversation', state => states.push(state)); expect(states).toEqual(['idle']); @@ -819,7 +851,7 @@ describe('retained run state', () => { const transport = createAssistantTransport('/api', 'note', socket); const states: AssistantRunState[] = []; const stop = transport.subscribeRunState!('conversation', state => states.push(state)); - const iterator = iterate(transport.openRun('conversation', { prompt: 'question' }, new AbortController().signal)); + const iterator = iterate(transport.openRun('conversation', { content: 'question' }, new AbortController().signal)); const first = iterator.next(); socket.close(); await expect(first).rejects.toBeInstanceOf(AssistantConnectionError); @@ -836,7 +868,7 @@ describe('retained run state', () => { segments: [{ id: 'answer', content: 'Recovered answer' }] } ], - earlierCursor: null + cursor: null }); expect(states[states.length - 1]).toBe('idle'); expect(fetch).toHaveBeenCalledTimes(3); @@ -846,14 +878,14 @@ describe('retained run state', () => { it('keeps a disconnected run visible when a retry is rejected', async () => { const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); - const first = collect(transport.openRun('conversation', { prompt: 'first' }, new AbortController().signal)); + const first = collect(transport.openRun('conversation', { content: 'first' }, new AbortController().signal)); socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'first' } }); socket.close(); await expect(first).rejects.toBeInstanceOf(AssistantConnectionError); const states: AssistantRunState[] = []; const stop = transport.subscribeRunState!('conversation', state => states.push(state)); - const retry = collect(transport.openRun('conversation', { prompt: 'retry' }, new AbortController().signal)); + const retry = collect(transport.openRun('conversation', { content: 'retry' }, new AbortController().signal)); socket.emit({ conversationId: 'conversation', type: 'run.failed', @@ -869,7 +901,7 @@ describe('retained run state', () => { expect(states).toContain('disconnected'); expect(states).not.toContain('idle'); - const accepted = collect(transport.openRun('conversation', { prompt: 'later' }, new AbortController().signal)); + const accepted = collect(transport.openRun('conversation', { content: 'later' }, new AbortController().signal)); socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'later' } }); socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'later' } }); await expect(accepted).resolves.toEqual([ @@ -895,7 +927,7 @@ describe('retained run state', () => { ); const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); - const run = collect(transport.openRun('conversation', { prompt: 'question' }, new AbortController().signal)); + const run = collect(transport.openRun('conversation', { content: 'question' }, new AbortController().signal)); socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); const staleHistory = transport.getMessages('conversation'); socket.close(); @@ -916,7 +948,7 @@ describe('retained run state', () => { const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); const controller = new AbortController(); - const iterator = iterate(transport.openRun('conversation', { prompt: 'question' }, controller.signal)); + const iterator = iterate(transport.openRun('conversation', { content: 'question' }, controller.signal)); const first = iterator.next(); socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); await first; @@ -937,7 +969,7 @@ describe('retained run state', () => { const states: AssistantRunState[] = []; const stop = transport.subscribeRunState!('conversation', running => states.push(running)); const controller = new AbortController(); - const iterator = iterate(transport.openRun('conversation', { prompt: 'question' }, controller.signal)); + const iterator = iterate(transport.openRun('conversation', { content: 'question' }, controller.signal)); const first = iterator.next(); socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); expect((await first).value).toEqual({ type: 'run.started', runId: 'run' }); 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 index efd62f34502..75e1db0c011 100644 --- 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 @@ -401,10 +401,9 @@ export async function* socketRun( type ConversationSummary = { id: string; title?: string; - ownerId?: string; - canSendMessage?: boolean; - createdAt?: string; - updatedAt?: string; + ownerId: string; + canSendMessage: boolean; + updatedAt: string; }; type MessagePage = { messages: ConversationMessage[]; cursor?: string | null }; @@ -427,11 +426,9 @@ export const createAssistantTransport = ( return { id: conversation.id, title: conversation.title, - ...(conversation.ownerId !== undefined ? { ownerId: conversation.ownerId } : {}), - ...(typeof conversation.canSendMessage === 'boolean' ? { canSendMessage: conversation.canSendMessage } : {}), - ...((conversation.updatedAt ?? conversation.createdAt) - ? { updatedAt: conversation.updatedAt ?? conversation.createdAt } - : {}) + ownerId: conversation.ownerId, + canSendMessage: conversation.canSendMessage, + updatedAt: conversation.updatedAt }; }; // The server names an untitled conversation by its creation time. @@ -460,12 +457,12 @@ export const createAssistantTransport = ( if (socket.signal?.aborted) stop(); return stop; }, - // Latest activity first, so the panel opens on it; undated ones stay last in server order (stable sort). + // Latest activity first, so the panel opens on it. listConversations: async () => (await collectionRequest(base, {}, onAuthError)) .map(conversation => ({ conversation, - time: Date.parse(conversation.updatedAt ?? conversation.createdAt ?? '') || 0 + time: Date.parse(conversation.updatedAt) || 0 })) .sort((a, b) => b.time - a.time) .map(({ conversation }) => toConversation(conversation)), @@ -489,7 +486,7 @@ export const createAssistantTransport = ( notifyRunState(runs, conversationId); } // The server pages from the latest message backwards; the panel renders oldest to newest. - return { messages: toVisibleMessages([...page.messages].reverse()), earlierCursor: page.cursor ?? null }; + return { messages: toVisibleMessages([...page.messages].reverse()), cursor: page.cursor ?? null }; }, openRun: (conversationId, body, signal) => socketRun( @@ -497,7 +494,7 @@ export const createAssistantTransport = ( { noteId, conversationId, - content: body.prompt + content: body.content }, signal, runs From 8301c5e69437fc0b46a469c39c7ad0ab4a525307 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 01:42:16 +0900 Subject: [PATCH 09/16] [ZEPPELIN-6742] Block duplicate assistant sends during active runs --- .../model/assistantTransport.spec.ts | 67 ++++++++++--------- .../assistant/model/assistantTransport.ts | 8 ++- .../projects/zeppelin-sdk/src/assistant.ts | 26 ------- 3 files changed, 43 insertions(+), 58 deletions(-) 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 index a64767cccfe..250ddcd94ef 100644 --- 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 @@ -304,16 +304,20 @@ describe('assistant transport', () => { expect(socket.listenerCount()).toBe(1); expect(socket.closeListenerCount()).toBe(1); const second = collect(socketRun(socket, message('second'), new AbortController().signal, tracking)); + await expect(second).resolves.toEqual([ + { type: 'run.failed', message: 'This conversation is still answering. Try again when it finishes.' } + ]); + expect(socket.sent).toHaveLength(1); socket.emit({ conversationId: 'c1', type: 'message.delta', payload: { messageId: 'old-answer', delta: 'old answer' } }); socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'old' } }); + const third = collect(socketRun(socket, message('third'), new AbortController().signal, tracking)); socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 409 } } }); - await expect(second).resolves.toEqual([ - { type: 'run.failed', runId: 'new', message: 'This conversation is still answering. Try again when it finishes.' } - ]); + await expect(third).resolves.toHaveLength(1); + expect(socket.sent).toHaveLength(2); }); it('tracks a queued start even when the consumer aborts before reading it', async () => { @@ -365,34 +369,26 @@ describe('assistant transport', () => { await expect(reconnect).resolves.toHaveLength(1); }); - it('ignores the rest of an abandoned run when the next message is sent', async () => { + it('blocks another send during an active run, then allows a new run after it ends', async () => { const socket = fakeSocket(); - const leave = new AbortController(); - const abandoned = iterate(socketRun(socket, message('first'), leave.signal, tracking)); - const firstEvent = abandoned.next(); + const first = iterate(socketRun(socket, message('first'), new AbortController().signal, tracking)); + 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' }); - leave.abort(); - await expect(abandoned.next()).rejects.toThrow(); + await expect( + collect(socketRun(socket, message('second'), new AbortController().signal, tracking)) + ).resolves.toEqual([ + { type: 'run.failed', message: 'This conversation is still answering. Try again when it finishes.' } + ]); + expect(socket.sent).toHaveLength(1); - // Sent while the abandoned run still streams: the server rejects it, and the old run's tail is not this run's. - const rejected = collect(socketRun(socket, message('second'), new AbortController().signal, tracking)); socket.emit({ conversationId: 'c1', type: 'message.delta', payload: { messageId: 'old', delta: 'tail' } }); socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'run_old' } }); - socket.emit({ - conversationId: 'c1', - type: 'run.failed', - payload: { runId: 'run_new', error: { status: 409 } } - }); - await expect(rejected).resolves.toEqual([ - { - type: 'run.failed', - runId: 'run_new', - message: 'This conversation is still answering. Try again when it finishes.' - } - ]); + 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); - // Once the old run ended, the next message runs normally. + // Once the old run ends, the next message can be sent normally. const next = collect(socketRun(socket, message('third'), new AbortController().signal, tracking)); socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'run_3' } }); socket.emit({ conversationId: 'c1', type: 'message.delta', payload: { messageId: 'm3', delta: 'ok' } }); @@ -776,7 +772,7 @@ describe('retired run subscriptions', () => { describe('retained run state', () => { it.each([true, false])( - 'keeps old completion separate from a new rejection (completion first: %s)', + 'ignores another run rejection without clearing the active run (completion first: %s)', async completionFirst => { const socket = fakeSocket(); const transport = createAssistantTransport('/api', 'note', socket); @@ -789,28 +785,39 @@ describe('retained run state', () => { await expect(first.next()).rejects.toThrow(); const states: AssistantRunState[] = []; const stop = transport.subscribeRunState!('conversation', running => states.push(running)); - const next = collect(transport.openRun('conversation', { content: 'new' }, new AbortController().signal)); const completion = () => socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'old' } }); - const rejection = () => + const unrelatedRejection = () => socket.emit({ conversationId: 'conversation', type: 'run.failed', payload: { runId: 'new', error: { status: 429 } } }); + let stateBeforeCompletion: AssistantRunState | undefined; if (completionFirst) { completion(); - rejection(); + unrelatedRejection(); } else { - rejection(); + unrelatedRejection(); + stateBeforeCompletion = states[states.length - 1]; completion(); } + expect(stateBeforeCompletion).toBe(completionFirst ? undefined : 'running'); + expect(states[states.length - 1]).toBe('idle'); + expect(socket.listenerCount()).toBe(0); + + const next = collect(transport.openRun('conversation', { content: 'new' }, new AbortController().signal)); + socket.emit({ + conversationId: 'conversation', + type: 'run.failed', + payload: { runId: 'new', error: { status: 429 } } + }); expect(await next).toEqual([ { type: 'run.failed', runId: 'new', message: 'The assistant is busy. Try again shortly.' } ]); - expect(states[0]).toBe('running'); expect(states[states.length - 1]).toBe('idle'); expect(socket.listenerCount()).toBe(0); + expect(socket.sent).toHaveLength(2); stop(); } ); 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 index 75e1db0c011..93430cc247f 100644 --- 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 @@ -315,8 +315,12 @@ export async function* socketRun( ): AsyncIterable { signal.throwIfAborted(); const { abandoned } = tracking; - if (tracking.pending.has(message.conversationId)) { - yield { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' }; + const pendingRun = tracking.pending.has(message.conversationId); + if (pendingRun || tracking.active.has(message.conversationId)) { + yield { + type: 'run.failed', + message: pendingRun ? 'The previous request is still being confirmed. Try again shortly.' : HTTP_MESSAGES[409] + }; return; } const pending = trackPendingRun(socket, message.conversationId, tracking); diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts index 13324721ca1..04264173710 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts @@ -10,8 +10,6 @@ * limitations under the License. */ -import type { AssistantParagraphRef, AssistantRevealResult, AssistantSlot } from './assistant-ui'; - import type { AssistantEvent as AssistantSocketEvent, AssistantSendMessage @@ -31,27 +29,3 @@ export interface AssistantSocket { /** The connection closed. The server sends a run's events only to the connection that started it. */ subscribeClose(listener: () => void): () => void; } - -/** Props the Angular notebook passes to the `./AssistantWorkspace` remote. A type alias, so it fits `ReactProps`. */ -export type AssistantHostProps = { - noteId: string; - /** The host's REST base (`.../api`); the remote calls the conversation REST API itself. */ - apiBase: string; - /** The host's notebook WebSocket, used to send messages and receive run events. */ - socket: AssistantSocket; - draftOwner?: string; - slots: AssistantSlot[]; - onAuthError?: (status: number, location: string | null) => void; - onError?: (error: unknown) => void; - /** Keeps the host sidebar to one open view at a time. */ - onPanelVisibilityChange?: (visible: boolean) => void; - subscribePanelClose?: (listener: () => void) => () => void; - /** Host-side scroll and highlight for a paragraph an answer links to; notebook DOM stays with Angular. */ - revealParagraph?: (paragraphId: string) => Promise; - /** The notebook's paragraphs in order, as data so labelling them never calls into the host. */ - paragraphs?: AssistantParagraphRef[]; - /** The notebook sidebar's width, which the panel shares so switching views keeps it. */ - panelWidth?: number; - /** A resize of the panel, at the end of a drag or per arrow key. */ - onPanelWidthChange?: (width: number) => void; -}; From d098067e56bfbd6ead97f7d4ac4880c886d93331 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sat, 17 Oct 2026 00:56:37 +0900 Subject: [PATCH 10/16] [ZEPPELIN-6742] Drop the unused redirect location from Assistant auth errors --- .../assistant/model/assistantTransport.spec.ts | 15 ++++++--------- .../assistant/model/assistantTransport.ts | 11 +++++------ 2 files changed, 11 insertions(+), 15 deletions(-) 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 index 250ddcd94ef..025b117ba24 100644 --- 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 @@ -58,7 +58,7 @@ const fakeSocket = () => { }; }; -const forNote = (noteId: string, onAuthError?: (status: number, location: string | null) => void) => +const forNote = (noteId: string, onAuthError?: (status: number) => void) => createAssistantTransport('https://example.test/zeppelin/api', noteId, fakeSocket(), onAuthError); const iterate = (iterable: AsyncIterable): AsyncIterator => iterable[Symbol.asyncIterator](); @@ -216,20 +216,17 @@ describe('assistant transport', () => { 'fetch', vi .fn() - .mockResolvedValueOnce(new Response(null, { status: 401, headers: { Location: '/login' } })) + .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, location: '/login' }); + 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, '/login'], - [405, null] - ]); + expect(onAuthError.mock.calls).toEqual([[401], [405]]); }); it('sends over the socket and yields only this conversation until a terminal event', async () => { @@ -461,10 +458,10 @@ describe('assistant transport', () => { }); it('explains a missing assistant configuration instead of the bare status', () => { - expect(new AssistantHttpError(503, null).message).toBe( + expect(new AssistantHttpError(503).message).toBe( 'The assistant is not configured on this server. Ask an administrator to enable it.' ); - expect(new AssistantHttpError(500, null).message).toBe('Assistant request failed with HTTP 500'); + expect(new AssistantHttpError(500).message).toBe('Assistant request failed with HTTP 500'); }); it('keeps only the ACK listener when aborted before the run starts', async () => { 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 index 93430cc247f..971acfe0641 100644 --- 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 @@ -45,7 +45,6 @@ const RUN_ERROR_FALLBACK = 'The assistant hit an error while answering. Try agai export class AssistantHttpError extends Error { constructor( public readonly status: number, - public readonly location: string | null, message = HTTP_MESSAGES[status] ?? `Assistant request failed with HTTP ${status}` ) { super(message); @@ -152,7 +151,7 @@ export const toVisibleMessages = (messages: ConversationMessage[]): AssistantMes }; const requestHeaders = { - // As the shell does in production, so the server answers 401/405 instead of a login page. + // Matches the headers the shell sends in production. 'X-Requested-With': 'XMLHttpRequest' }; @@ -161,12 +160,12 @@ 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, location: string | null) => void; +export type AssistantAuthErrorHandler = (status: number) => void; const httpError = (response: Response, onAuthError?: AssistantAuthErrorHandler): AssistantHttpError => { - const error = new AssistantHttpError(response.status, response.headers.get('Location')); + const error = new AssistantHttpError(response.status); if (error.status === 401 || error.status === 405) { - onAuthError?.(error.status, error.location); + onAuthError?.(error.status); } return error; }; @@ -199,7 +198,7 @@ const collectionRequest = async (...args: Parameters): Pr return await requestJson(...args); } catch (error) { if (error instanceof AssistantHttpError && error.status === 404) { - throw new AssistantHttpError(404, error.location, NOT_AVAILABLE_MESSAGE); + throw new AssistantHttpError(404, NOT_AVAILABLE_MESSAGE); } throw error; } From 42f456587d7387471ce6b2877c7af50c8e2d2c17 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sat, 17 Oct 2026 01:13:06 +0900 Subject: [PATCH 11/16] [ZEPPELIN-6742] Simplify Assistant run tracking to the request being read --- .../model/assistantTransport.spec.ts | 380 +++--------------- .../assistant/model/assistantTransport.ts | 197 +-------- .../projects/zeppelin-sdk/src/assistant.ts | 4 - 3 files changed, 64 insertions(+), 517 deletions(-) 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 index 025b117ba24..cb8989d86c1 100644 --- 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 @@ -10,7 +10,7 @@ * limitations under the License. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AssistantSendMessage, AssistantSocketEvent } from '@zeppelin/sdk'; @@ -18,7 +18,6 @@ import { AssistantHttpError, createAssistantTransport, mapSocketEvent, - createRunTracking, scopeTransport, socketRun, toVisibleMessages @@ -28,8 +27,7 @@ import { AssistantStreamError, type AssistantAnswerMessage, type AssistantConversation, - type AssistantRunEvent, - type AssistantRunState + type AssistantRunEvent } from './assistantContract'; import { answerText, answerToolCalls, joinPages } from './messageHistory'; @@ -74,11 +72,6 @@ const collect = async (events: AsyncIterable): Promise ({ noteId: 'n', conversationId: 'c1', content }); describe('assistant transport', () => { - // One note's sends share their run tracking, as the transport does. - let tracking = createRunTracking(); - beforeEach(() => { - tracking = createRunTracking(); - }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); @@ -269,7 +262,7 @@ describe('assistant transport', () => { it('ends on a run failure that arrives before run.started', async () => { const socket = fakeSocket(); - const pending = collect(socketRun(socket, message(), new AbortController().signal, tracking)); + const pending = collect(socketRun(socket, message(), new AbortController().signal)); socket.emit({ conversationId: 'c1', type: 'run.failed', @@ -284,117 +277,72 @@ describe('assistant transport', () => { ]); }); - it('blocks resending an aborted request until its delayed start is tracked', async () => { + 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, tracking)); + const first = iterate(socketRun(socket, message('first'), leave.signal)); const response = first.next(); leave.abort(); await expect(response).rejects.toMatchObject({ name: 'AbortError' }); - await expect( - collect(socketRun(socket, message('second'), new AbortController().signal, tracking)) - ).resolves.toEqual([ - { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' } - ]); - expect(socket.sent).toHaveLength(1); - socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'old' } }); - expect(socket.listenerCount()).toBe(1); - expect(socket.closeListenerCount()).toBe(1); - const second = collect(socketRun(socket, message('second'), new AbortController().signal, tracking)); + 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', message: 'This conversation is still answering. Try again when it finishes.' } + { + type: 'run.failed', + runId: 'second', + message: 'This conversation is still answering. Try again when it finishes.' + } ]); - expect(socket.sent).toHaveLength(1); - socket.emit({ - conversationId: 'c1', - type: 'message.delta', - payload: { messageId: 'old-answer', delta: 'old answer' } - }); - socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'old' } }); - const third = collect(socketRun(socket, message('third'), new AbortController().signal, tracking)); - socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 409 } } }); - await expect(third).resolves.toHaveLength(1); - expect(socket.sent).toHaveLength(2); }); - it('tracks a queued start even when the consumer aborts before reading it', async () => { + it('ends the run and releases its listeners when the connection closes', async () => { const socket = fakeSocket(); - const controller = new AbortController(); - const first = iterate(socketRun(socket, message(), controller.signal, tracking)); - const response = first.next(); - socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'queued' } }); - controller.abort(); - await expect(response).rejects.toMatchObject({ name: 'AbortError' }); - expect(tracking.abandoned.has('queued')).toBe(true); - expect(socket.listenerCount()).toBe(1); - socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'queued' } }); - expect(socket.listenerCount()).toBe(0); - }); - - it('shares pending requests across transports on the same host connection', async () => { - const connectionKey = {}; - const socket = { ...fakeSocket(), connectionKey }; - const old = createAssistantTransport('/api', 'note', socket); - const leave = new AbortController(); - const response = iterate(old.openRun('c1', { content: 'first' }, leave.signal)).next(); - leave.abort(); - await expect(response).rejects.toMatchObject({ name: 'AbortError' }); - const next = createAssistantTransport('/api', 'note', { ...socket }); - await expect(collect(next.openRun('c1', { content: 'second' }, new AbortController().signal))).resolves.toEqual([ - { type: 'run.failed', message: 'The previous request is still being confirmed. Try again shortly.' } - ]); - expect(socket.sent).toHaveLength(1); - socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'old', error: { status: 403 } } }); + const pending = collect(socketRun(socket, message(), new AbortController().signal)); + socket.close(); + await expect(pending).rejects.toBeInstanceOf(AssistantConnectionError); expect(socket.listenerCount()).toBe(0); - const sent = collect(next.openRun('c1', { content: 'second' }, new AbortController().signal)); - socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 409 } } }); - await expect(sent).resolves.toHaveLength(1); - expect(socket.sent).toHaveLength(2); + expect(socket.closeListenerCount()).toBe(0); }); - it('releases unconfirmed sends when the connection closes', async () => { + it('rethrows a failed send and releases its listeners', async () => { const socket = fakeSocket(); - const controller = new AbortController(); - const response = iterate(socketRun(socket, message(), controller.signal, tracking)).next(); - controller.abort(); - await expect(response).rejects.toMatchObject({ name: 'AbortError' }); - socket.close(); + 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); - const reconnect = collect(socketRun(socket, message(), new AbortController().signal, tracking)); - socket.emit({ conversationId: 'c1', type: 'run.failed', payload: { runId: 'new', error: { status: 403 } } }); - await expect(reconnect).resolves.toHaveLength(1); }); - it('blocks another send during an active run, then allows a new run after it ends', async () => { + 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, tracking)); + 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' }); - await expect( - collect(socketRun(socket, message('second'), new AbortController().signal, tracking)) - ).resolves.toEqual([ - { type: 'run.failed', message: 'This conversation is still answering. Try again when it finishes.' } + + 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.' + } ]); - expect(socket.sent).toHaveLength(1); 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); - - // Once the old run ends, the next message can be sent normally. - const next = collect(socketRun(socket, message('third'), new AbortController().signal, tracking)); - socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'run_3' } }); - socket.emit({ conversationId: 'c1', type: 'message.delta', payload: { messageId: 'm3', delta: 'ok' } }); - socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'run_3' } }); - await expect(next).resolves.toEqual([ - { type: 'run.started', runId: 'run_3' }, - { type: 'message.delta', messageId: 'm3', delta: 'ok' }, - { type: 'run.completed', runId: 'run_3' } - ]); }); it('words run.failed by its HTTP status and falls back for the rest', () => { @@ -464,50 +412,46 @@ describe('assistant transport', () => { expect(new AssistantHttpError(500).message).toBe('Assistant request failed with HTTP 500'); }); - it('keeps only the ACK listener when aborted before the run starts', async () => { + 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, tracking)); + const run = iterate(socketRun(socket, message(), controller.signal)); const pending = run.next(); - await vi.waitFor(() => expect(socket.listenerCount()).toBe(2)); + await vi.waitFor(() => expect(socket.listenerCount()).toBe(1)); controller.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); - expect(socket.listenerCount()).toBe(1); - socket.emit({ conversationId: 'c1', type: 'run.started', payload: { runId: 'late' } }); - expect(socket.listenerCount()).toBe(1); - socket.emit({ conversationId: 'c1', type: 'run.completed', payload: { runId: 'late' } }); 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, tracking)); + 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 when the run goes quiet past the idle timeout', async () => { + 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, tracking, 1000)); + 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(1); - socket.close(); 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, tracking)); + 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.'); @@ -751,74 +695,7 @@ describe('assistant transport', () => { }); }); -describe('retired run subscriptions', () => { - it('does not subscribe or throw for an inactive or aborted session', () => { - const inner = createAssistantTransport('/api', 'note', fakeSocket()); - const subscribe = vi.spyOn(inner, 'subscribeRunState'); - const signal = new AbortController(); - const scoped = scopeTransport(inner, signal.signal); - signal.abort(); - expect(() => scoped.transport.subscribeRunState!('conversation', vi.fn())()).not.toThrow(); - expect(subscribe).not.toHaveBeenCalled(); - const retired = scopeTransport(inner); - retired.setActive(false); - expect(() => retired.transport.subscribeRunState!('conversation', vi.fn())()).not.toThrow(); - expect(subscribe).not.toHaveBeenCalled(); - }); -}); - -describe('retained run state', () => { - it.each([true, false])( - 'ignores another run rejection without clearing the active run (completion first: %s)', - async completionFirst => { - const socket = fakeSocket(); - const transport = createAssistantTransport('/api', 'note', socket); - const controller = new AbortController(); - const first = iterate(transport.openRun('conversation', { content: 'old' }, controller.signal)); - const started = first.next(); - socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'old' } }); - await started; - controller.abort(); - await expect(first.next()).rejects.toThrow(); - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', running => states.push(running)); - const completion = () => - socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'old' } }); - const unrelatedRejection = () => - socket.emit({ - conversationId: 'conversation', - type: 'run.failed', - payload: { runId: 'new', error: { status: 429 } } - }); - let stateBeforeCompletion: AssistantRunState | undefined; - if (completionFirst) { - completion(); - unrelatedRejection(); - } else { - unrelatedRejection(); - stateBeforeCompletion = states[states.length - 1]; - completion(); - } - expect(stateBeforeCompletion).toBe(completionFirst ? undefined : 'running'); - expect(states[states.length - 1]).toBe('idle'); - expect(socket.listenerCount()).toBe(0); - - const next = collect(transport.openRun('conversation', { content: 'new' }, new AbortController().signal)); - socket.emit({ - conversationId: 'conversation', - type: 'run.failed', - payload: { runId: 'new', error: { status: 429 } } - }); - expect(await next).toEqual([ - { type: 'run.failed', runId: 'new', message: 'The assistant is busy. Try again shortly.' } - ]); - expect(states[states.length - 1]).toBe('idle'); - expect(socket.listenerCount()).toBe(0); - expect(socket.sent).toHaveLength(2); - stop(); - } - ); - +describe('conversation list', () => { it('uses the server conversation response without a running field', async () => { const conversation = { id: 'conversation', @@ -831,163 +708,6 @@ describe('retained run state', () => { vi.stubGlobal('fetch', fetch); const transport = createAssistantTransport('/api', 'note', fakeSocket()); expect(await transport.listConversations()).toEqual([conversation]); - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', state => states.push(state)); - expect(states).toEqual(['idle']); expect(fetch).toHaveBeenCalledTimes(1); - stop(); - }); - - it('keeps a disconnected run visible until history reload succeeds', async () => { - const fetch = vi - .fn() - .mockRejectedValueOnce(new TypeError('Offline')) - .mockResolvedValueOnce(new Response(JSON.stringify({ body: { messages: [], cursor: null } }))) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - body: { messages: [{ id: 'answer', role: 'assistant', content: 'Recovered answer' }], cursor: null } - }) - ) - ); - vi.stubGlobal('fetch', fetch); - const socket = fakeSocket(); - const transport = createAssistantTransport('/api', 'note', socket); - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', state => states.push(state)); - const iterator = iterate(transport.openRun('conversation', { content: 'question' }, new AbortController().signal)); - const first = iterator.next(); - socket.close(); - await expect(first).rejects.toBeInstanceOf(AssistantConnectionError); - expect(states[states.length - 1]).toBe('disconnected'); - await expect(transport.getMessages('conversation')).rejects.toThrow('Offline'); - expect(states[states.length - 1]).toBe('disconnected'); - await transport.getMessages('conversation', 'older-page'); - expect(states[states.length - 1]).toBe('disconnected'); - await expect(transport.getMessages('conversation')).resolves.toEqual({ - messages: [ - { - id: 'answer', - role: 'assistant', - segments: [{ id: 'answer', content: 'Recovered answer' }] - } - ], - cursor: null - }); - expect(states[states.length - 1]).toBe('idle'); - expect(fetch).toHaveBeenCalledTimes(3); - stop(); - }); - - it('keeps a disconnected run visible when a retry is rejected', async () => { - const socket = fakeSocket(); - const transport = createAssistantTransport('/api', 'note', socket); - const first = collect(transport.openRun('conversation', { content: 'first' }, new AbortController().signal)); - socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'first' } }); - socket.close(); - await expect(first).rejects.toBeInstanceOf(AssistantConnectionError); - - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', state => states.push(state)); - const retry = collect(transport.openRun('conversation', { content: 'retry' }, new AbortController().signal)); - socket.emit({ - conversationId: 'conversation', - type: 'run.failed', - payload: { runId: 'retry', error: { status: 409 } } - }); - await expect(retry).resolves.toEqual([ - { - type: 'run.failed', - runId: 'retry', - message: 'This conversation is still answering. Try again when it finishes.' - } - ]); - expect(states).toContain('disconnected'); - expect(states).not.toContain('idle'); - - const accepted = collect(transport.openRun('conversation', { content: 'later' }, new AbortController().signal)); - socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'later' } }); - socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'later' } }); - await expect(accepted).resolves.toEqual([ - { type: 'run.started', runId: 'later' }, - { type: 'run.completed', runId: 'later' } - ]); - expect(states).toContain('running'); - expect(states.at(-1)).toBe('idle'); - stop(); - }); - - it('does not recover a disconnect with history requested before it', async () => { - let resolveHistory: (response: Response) => void = () => undefined; - const delayedHistory = new Promise(resolve => { - resolveHistory = resolve; - }); - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockReturnValueOnce(delayedHistory) - .mockResolvedValueOnce(new Response(JSON.stringify({ body: { messages: [], cursor: null } }))) - ); - const socket = fakeSocket(); - const transport = createAssistantTransport('/api', 'note', socket); - const run = collect(transport.openRun('conversation', { content: 'question' }, new AbortController().signal)); - socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); - const staleHistory = transport.getMessages('conversation'); - socket.close(); - await expect(run).rejects.toBeInstanceOf(AssistantConnectionError); - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', state => states.push(state)); - - resolveHistory(new Response(JSON.stringify({ body: { messages: [], cursor: null } }))); - await staleHistory; - expect(states).toEqual(['disconnected']); - - await transport.getMessages('conversation'); - expect(states).toEqual(['disconnected', 'idle']); - stop(); - }); - - it('clears a retained run on disconnect and releases listeners', async () => { - const socket = fakeSocket(); - const transport = createAssistantTransport('/api', 'note', socket); - const controller = new AbortController(); - const iterator = iterate(transport.openRun('conversation', { content: 'question' }, controller.signal)); - const first = iterator.next(); - socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); - await first; - controller.abort(); - await expect(iterator.next()).rejects.toThrow(); - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', running => states.push(running)); - socket.close(); - expect(states).toEqual(['running', 'disconnected']); - expect(socket.listenerCount()).toBe(0); - expect(socket.closeListenerCount()).toBe(0); - stop(); - }); - - it('follows an abandoned accepted run until completion and releases socket listeners', async () => { - const socket = fakeSocket(); - const transport = createAssistantTransport('/api', 'note', socket); - const states: AssistantRunState[] = []; - const stop = transport.subscribeRunState!('conversation', running => states.push(running)); - const controller = new AbortController(); - const iterator = iterate(transport.openRun('conversation', { content: 'question' }, controller.signal)); - const first = iterator.next(); - socket.emit({ conversationId: 'conversation', type: 'run.started', payload: { runId: 'run' } }); - expect((await first).value).toEqual({ type: 'run.started', runId: 'run' }); - controller.abort(); - await expect(iterator.next()).rejects.toThrow(); - const reopened: AssistantRunState[] = []; - const stopReopened = transport.subscribeRunState!('conversation', running => reopened.push(running)); - expect(reopened).toEqual(['running']); - socket.emit({ conversationId: 'conversation', type: 'run.completed', payload: { runId: 'run' } }); - expect(reopened).toEqual(['running', 'idle']); - expect(states).toContain('running'); - expect(socket.listenerCount()).toBe(0); - expect(socket.closeListenerCount()).toBe(0); - stop(); - stopReopened(); }); }); 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 index 971acfe0641..20214a02ca0 100644 --- 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 @@ -18,7 +18,6 @@ import { type AssistantMessage, type AssistantMessagePage, type AssistantRunEvent, - type AssistantRunState, type AssistantToolCallRef, type AssistantTransport } from './assistantContract'; @@ -204,126 +203,28 @@ const collectionRequest = async (...args: Parameters): Pr } }; -const MAX_ABANDONED_RUNS = 200; /** - * Runs on one host connection. Pending sends survive note switches until acknowledged; abandoned run ids keep - * later sends from following their events. The server continues runs after the UI stops listening. + * 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 PendingRun { - abandoned: boolean; +interface RunSlot { + conversationId: string; + phase: 'pending' | 'running'; runId?: string; } -export interface RunTracking { - abandoned: Set; - pending: Map; - active: Map; - disconnected: Map; - listeners: Map void>>; - stopTracking?: () => void; -} -export const createRunTracking = (): RunTracking => ({ - abandoned: new Set(), - pending: new Map(), - active: new Map(), - disconnected: new Map(), - listeners: new Map() -}); -const runState = (tracking: RunTracking, conversationId: string): AssistantRunState => - tracking.disconnected.has(conversationId) - ? 'disconnected' - : tracking.pending.has(conversationId) || tracking.active.has(conversationId) - ? 'running' - : 'idle'; -const notifyRunState = (tracking: RunTracking, conversationId: string) => { - tracking.listeners.get(conversationId)?.forEach(listener => listener(runState(tracking, conversationId))); -}; -const releaseTracking = (tracking: RunTracking) => { - if (!tracking.pending.size && !tracking.active.size) tracking.stopTracking?.(); -}; -const connectionRuns = new WeakMap(); -const runsFor = (socket: AssistantSocket): RunTracking => { - const key = socket.connectionKey ?? socket; - let runs = connectionRuns.get(key); - if (!runs) connectionRuns.set(key, (runs = createRunTracking())); - return runs; -}; -const abandon = (tracking: RunTracking, runId: string) => { - tracking.abandoned.add(runId); - if (tracking.abandoned.size > MAX_ABANDONED_RUNS) { - const oldest = tracking.abandoned.values().next().value; - if (oldest !== undefined) tracking.abandoned.delete(oldest); - } -}; - -// ACKs belong to the connection, even if the note or conversation that sent the request has unmounted. -const trackPendingRun = (socket: AssistantSocket, conversationId: string, tracking: RunTracking): PendingRun => { - const pending: PendingRun = { abandoned: false }; - tracking.pending.set(conversationId, pending); - notifyRunState(tracking, conversationId); - if (!tracking.stopTracking) { - const unsubscribe = socket.subscribe(event => { - const request = tracking.pending.get(event.conversationId); - const payload = isRecord(event.payload) ? event.payload : {}; - const runId = typeof payload.runId === 'string' ? payload.runId : undefined; - if (event.type === 'run.started' && runId && request) { - request.runId = runId; - tracking.active.set(event.conversationId, runId); - tracking.disconnected.delete(event.conversationId); - if (request.abandoned) abandon(tracking, runId); - tracking.pending.delete(event.conversationId); - } else if (event.type === 'run.failed' || event.type === 'run.completed') { - const activeRun = tracking.active.get(event.conversationId); - if (activeRun && (!runId || runId === activeRun)) { - tracking.active.delete(event.conversationId); - } else if (request && !(runId && tracking.abandoned.has(runId))) { - tracking.pending.delete(event.conversationId); - } else return; - } else return; - notifyRunState(tracking, event.conversationId); - releaseTracking(tracking); - }); - const unsubscribeClose = socket.subscribeClose(() => { - const conversations = new Set([...Array.from(tracking.pending.keys()), ...Array.from(tracking.active.keys())]); - tracking.pending.clear(); - tracking.active.clear(); - tracking.abandoned.clear(); - conversations.forEach(id => tracking.disconnected.set(id, {})); - conversations.forEach(id => notifyRunState(tracking, id)); - tracking.stopTracking?.(); - }); - tracking.stopTracking = () => { - unsubscribe(); - unsubscribeClose(); - tracking.stopTracking = undefined; - }; - } - return pending; -}; /** * 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; events of an abandoned run - * on the same conversation are skipped. + * Until this message's run.started arrives, only a rejection (run.failed) belongs to it. */ export async function* socketRun( socket: AssistantSocket, message: AssistantSendMessage, signal: AbortSignal, - tracking: RunTracking, idleTimeoutMs = RUN_IDLE_TIMEOUT_MS ): AsyncIterable { signal.throwIfAborted(); - const { abandoned } = tracking; - const pendingRun = tracking.pending.has(message.conversationId); - if (pendingRun || tracking.active.has(message.conversationId)) { - yield { - type: 'run.failed', - message: pendingRun ? 'The previous request is still being confirmed. Try again shortly.' : HTTP_MESSAGES[409] - }; - return; - } - const pending = trackPendingRun(socket, message.conversationId, tracking); - let sent = false; + const slot: RunSlot = { conversationId: message.conversationId, phase: 'pending' }; const queue: Array = []; let wake: (() => void) | undefined; const push = (item: AssistantRunEvent | Error) => { @@ -340,18 +241,10 @@ export async function* socketRun( } }); const unsubscribeClose = socket.subscribeClose(() => push(new AssistantConnectionError())); - const onAbort = () => { - pending.abandoned = true; - if (pending.runId) abandon(tracking, pending.runId); - wake?.(); - }; + const onAbort = () => wake?.(); signal.addEventListener('abort', onAbort, { once: true }); - let started = false; - let ownRunId: string | undefined; - let ended = false; try { socket.send(message); - sent = true; while (true) { signal.throwIfAborted(); if (queue.length === 0) { @@ -368,33 +261,18 @@ export async function* socketRun( if (item === undefined) continue; if (item instanceof Error) throw item; const runId = 'runId' in item ? item.runId : undefined; - if (runId && runId !== ownRunId && abandoned.has(runId)) { - if (isTerminal(item)) abandoned.delete(runId); - continue; - } // Once running, only its own terminal event ends it; deltas have no run id, but runs are one at a time. - if (started && isTerminal(item) && runId && ownRunId && runId !== ownRunId) continue; - if (!started) { + if (slot.phase === 'running' && isTerminal(item) && runId && slot.runId && runId !== slot.runId) continue; + if (slot.phase === 'pending') { if (item.type === 'run.started') { - started = true; - ownRunId = item.runId; + slot.phase = 'running'; + slot.runId = item.runId; } else if (item.type !== 'run.failed') continue; } - if (isTerminal(item)) ended = true; yield item; - if (ended) return; + if (isTerminal(item)) return; } } finally { - if (!ended && sent) { - pending.abandoned = true; - const runId = pending.runId ?? ownRunId; - if (runId) abandon(tracking, runId); - } - if (!sent || ended) { - if (tracking.pending.get(message.conversationId) === pending) tracking.pending.delete(message.conversationId); - notifyRunState(tracking, message.conversationId); - releaseTracking(tracking); - } signal.removeEventListener('abort', onAbort); unsubscribe(); unsubscribeClose(); @@ -424,7 +302,6 @@ export const createAssistantTransport = ( onAuthError?: AssistantAuthErrorHandler ): AssistantTransport => { const base = `${apiBase.replace(/\/$/, '')}/notes/${encodeURIComponent(noteId)}/conversations`; - const runs = runsFor(socket); const toConversation = (conversation: ConversationSummary): AssistantConversation => { return { id: conversation.id, @@ -440,26 +317,6 @@ export const createAssistantTransport = ( return trimmed ? { title: trimmed.length > 80 ? `${trimmed.slice(0, 79)}…` : trimmed } : {}; }; return { - subscribeRunState: (conversationId, listener) => { - let listeners = runs.listeners.get(conversationId); - if (!listeners) runs.listeners.set(conversationId, (listeners = new Set())); - let stopped = false; - const observe = (state: AssistantRunState) => { - listener(state); - }; - listeners.add(observe); - observe(runState(runs, conversationId)); - const stop = () => { - if (stopped) return; - stopped = true; - listeners.delete(observe); - if (!listeners.size) runs.listeners.delete(conversationId); - socket.signal?.removeEventListener('abort', stop); - }; - socket.signal?.addEventListener('abort', stop, { once: true }); - if (socket.signal?.aborted) stop(); - return stop; - }, // Latest activity first, so the panel opens on it. listConversations: async () => (await collectionRequest(base, {}, onAuthError)) @@ -478,16 +335,11 @@ export const createAssistantTransport = ( }, getMessages: async (conversationId, before): Promise => { const cursor = before ? `&cursor=${encodeURIComponent(before)}` : ''; - const disconnectedAtRequest = runs.disconnected.get(conversationId); const page = await requestJson( `${base}/${encodeURIComponent(conversationId)}/messages?limit=${HISTORY_PAGE_SIZE}${cursor}`, {}, onAuthError ); - if (!before && disconnectedAtRequest && runs.disconnected.get(conversationId) === disconnectedAtRequest) { - runs.disconnected.delete(conversationId); - notifyRunState(runs, conversationId); - } // The server pages from the latest message backwards; the panel renders oldest to newest. return { messages: toVisibleMessages([...page.messages].reverse()), cursor: page.cursor ?? null }; }, @@ -499,8 +351,7 @@ export const createAssistantTransport = ( conversationId, content: body.content }, - signal, - runs + signal ) }; }; @@ -512,7 +363,6 @@ export const createAssistantTransport = ( export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortSignal) => { let active = true; const runs = new Set(); - const subscriptions = new Set<() => void>(); const assertActive = () => { if (!active || sessionSignal?.aborted) { throw new DOMException('Notebook changed', 'AbortError'); @@ -534,24 +384,6 @@ export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortS createConversation: body => guarded(() => inner.createConversation(body)), deleteConversation: id => guarded(() => inner.deleteConversation(id)), getMessages: (id, before) => guarded(() => inner.getMessages(id, before)), - ...(inner.subscribeRunState - ? { - subscribeRunState: (id: string, listener: (state: AssistantRunState) => void) => { - if (!active || sessionSignal?.aborted) return () => undefined; - const unsubscribe = inner.subscribeRunState!(id, state => { - if (active && !sessionSignal?.aborted) listener(state); - }); - const stop = () => { - if (!subscriptions.delete(stop)) return; - unsubscribe(); - sessionSignal?.removeEventListener('abort', stop); - }; - subscriptions.add(stop); - sessionSignal?.addEventListener('abort', stop, { once: true }); - return stop; - } - } - : {}), async *openRun(id, body, signal) { assertActive(); const controller = new AbortController(); @@ -591,7 +423,6 @@ export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortS if (!next) { runs.forEach(controller => controller.abort()); runs.clear(); - subscriptions.forEach(stop => stop()); } } }; diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts index 04264173710..071950ac939 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant.ts @@ -20,10 +20,6 @@ export type { AssistantSocketEvent }; /** The host's notebook WebSocket, narrowed to the assistant ops. */ export interface AssistantSocket { - /** Stable host connection identity across notebook adapters. */ - readonly connectionKey?: object; - /** Aborted when the host retires this notebook/account adapter, without closing the shared connection. */ - readonly signal?: AbortSignal; 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. */ From a5127fe3122ee77797f238b62330f0656ed175ec Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sat, 17 Oct 2026 08:32:03 +0900 Subject: [PATCH 12/16] [ZEPPELIN-6742] Drop the unused Assistant run-state contract --- .../src/entities/assistant/model/assistantContract.ts | 4 ---- 1 file changed, 4 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 e2792bdf48f..af66f96a66e 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 @@ -60,8 +60,6 @@ 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. */ listConversations(): Promise; @@ -69,8 +67,6 @@ export interface AssistantTransport { 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; } From 23880cd1a60cb0f522c85201e4b7b20caeb257bd Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sat, 17 Oct 2026 22:57:56 +0900 Subject: [PATCH 13/16] [ZEPPELIN-6742] Retire assistant transport scopes after note changes --- .../assistant/model/assistantContract.ts | 8 +- .../model/assistantTransport.spec.ts | 76 +++++++++++++++++-- .../assistant/model/assistantTransport.ts | 75 ++++++++++-------- 3 files changed, 118 insertions(+), 41 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 af66f96a66e..84ba0574bb1 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 @@ -62,11 +62,11 @@ export type AssistantRunEvent = export interface AssistantTransport { /** Conversations of the note the transport was created for. */ - listConversations(): Promise; - createConversation(body: { title?: string }): Promise; - deleteConversation(conversationId: string): Promise; + listConversations(signal?: AbortSignal): Promise; + createConversation(body: { title?: string }, signal?: AbortSignal): Promise; + deleteConversation(conversationId: string, signal?: AbortSignal): Promise; /** The latest page of history, or the page before `before` (a page cursor). */ - getMessages(conversationId: string, before?: string): Promise; + getMessages(conversationId: string, before?: string, signal?: AbortSignal): Promise; /** Aborting stops local event consumption; it does not cancel a run already accepted by the server. */ openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable; } 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 index cb8989d86c1..4d2f3715cc6 100644 --- 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 @@ -222,6 +222,24 @@ describe('assistant transport', () => { expect(onAuthError.mock.calls).toEqual([[401], [405]]); }); + it('does not redirect for an auth response that arrives after the note scope ends', async () => { + let resolveFetch: (response: Response) => void = () => undefined; + const fetch = vi.fn( + (_url: string, _init: RequestInit) => new Promise(resolve => (resolveFetch = resolve)) + ); + vi.stubGlobal('fetch', fetch); + const onAuthError = vi.fn(); + const scoped = scopeTransport(forNote('n', onAuthError)); + const request = scoped.transport.listConversations(); + const signal = fetch.mock.calls[0][1].signal as AbortSignal; + + scoped.deactivate(); + expect(signal.aborted).toBe(true); + resolveFetch(new Response(null, { status: 401 })); + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(onAuthError).not.toHaveBeenCalled(); + }); + it('sends over the socket and yields only this conversation until a terminal event', async () => { const socket = fakeSocket(); const transport = createAssistantTransport('https://example.test/api', 'n', socket); @@ -493,9 +511,9 @@ describe('assistant transport', () => { const list = scoped.transport.listConversations(); const run = iterate(scoped.transport.openRun('t', { content: 'hi' }, new AbortController().signal)); await run.next(); - expect(inner.listConversations).toHaveBeenCalledWith(); + expect(inner.listConversations).toHaveBeenCalledWith(expect.any(AbortSignal)); - scoped.setActive(false); + scoped.deactivate(); resolveList([]); await expect(list).rejects.toMatchObject({ name: 'AbortError' }); @@ -503,6 +521,56 @@ describe('assistant transport', () => { await expect(scoped.transport.getMessages('t')).rejects.toMatchObject({ name: 'AbortError' }); }); + it('retires an old REST request permanently while a fresh note scope can read', async () => { + let resolveOld: (conversations: []) => void = () => undefined; + const inner = { + listConversations: vi + .fn() + .mockImplementationOnce(() => new Promise<[]>(resolve => (resolveOld = resolve))) + .mockResolvedValueOnce([]), + createConversation: vi.fn(), + deleteConversation: vi.fn(), + getMessages: vi.fn(), + openRun: vi.fn() + }; + const oldScope = scopeTransport(inner); + const oldRead = oldScope.transport.listConversations(); + const oldSignal = inner.listConversations.mock.calls[0][0] as AbortSignal; + + oldScope.deactivate(); + expect(oldSignal.aborted).toBe(true); + const newScope = scopeTransport(inner); + await expect(newScope.transport.listConversations()).resolves.toEqual([]); + resolveOld([]); + await expect(oldRead).rejects.toMatchObject({ name: 'AbortError' }); + expect(oldScope.active).toBe(false); + }); + + it('passes a caller abort to REST without retiring the note scope', async () => { + let resolveRead: (conversations: []) => void = () => undefined; + const inner = { + listConversations: vi + .fn() + .mockImplementationOnce(() => new Promise<[]>(resolve => (resolveRead = resolve))) + .mockResolvedValueOnce([]), + createConversation: vi.fn(), + deleteConversation: vi.fn(), + getMessages: vi.fn(), + openRun: vi.fn() + }; + const scoped = scopeTransport(inner); + const caller = new AbortController(); + const read = scoped.transport.listConversations(caller.signal); + const requestSignal = inner.listConversations.mock.calls[0][0] as AbortSignal; + + caller.abort(); + expect(requestSignal.aborted).toBe(true); + resolveRead([]); + await expect(read).rejects.toMatchObject({ name: 'AbortError' }); + expect(scoped.active).toBe(true); + await expect(scoped.transport.listConversations()).resolves.toEqual([]); + }); + it('retires pending creations and active runs when the host session aborts', async () => { const session = new AbortController(); let resolveCreate: (value: AssistantConversation) => void = () => undefined; @@ -525,8 +593,6 @@ describe('assistant transport', () => { } }; const scoped = scopeTransport(inner, session.signal); - scoped.setActive(false); - scoped.setActive(true); const creation = scoped.transport.createConversation({}); const run = iterate(scoped.transport.openRun('c1', { content: 'hi' }, new AbortController().signal)); await run.next(); @@ -542,7 +608,7 @@ describe('assistant transport', () => { await expect(terminal).rejects.toMatchObject({ name: 'AbortError' }); expect(runSignal?.aborted).toBe(true); expect(scoped.active).toBe(false); - scoped.setActive(true); + scoped.deactivate(); expect(scoped.active).toBe(false); await expect(scoped.transport.listConversations()).rejects.toMatchObject({ name: 'AbortError' }); expect(inner.listConversations).not.toHaveBeenCalled(); 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 index 20214a02ca0..d4cd709cd2a 100644 --- 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 @@ -174,6 +174,7 @@ const requestJson = async ( init: { method?: string; body?: unknown; signal?: AbortSignal } = {}, onAuthError?: AssistantAuthErrorHandler ): Promise => { + init.signal?.throwIfAborted(); const response = await fetch(url, { method: init.method ?? 'GET', ...(init.signal ? { signal: init.signal } : {}), @@ -184,10 +185,12 @@ const requestJson = async ( : { ...requestHeaders, Accept: 'application/json', 'Content-Type': 'application/json' }, body: init.body === undefined ? undefined : JSON.stringify(init.body) }); + init.signal?.throwIfAborted(); if (!response.ok) { throw httpError(response, onAuthError); } const text = await response.text(); + init.signal?.throwIfAborted(); return (text ? unwrapBody(JSON.parse(text)) : undefined) as T; }; @@ -318,26 +321,26 @@ export const createAssistantTransport = ( }; return { // Latest activity first, so the panel opens on it. - listConversations: async () => - (await collectionRequest(base, {}, onAuthError)) + listConversations: async signal => + (await collectionRequest(base, { signal }, onAuthError)) .map(conversation => ({ conversation, time: Date.parse(conversation.updatedAt) || 0 })) .sort((a, b) => b.time - a.time) .map(({ conversation }) => toConversation(conversation)), - createConversation: async ({ title } = {}) => + createConversation: async ({ title } = {}, signal) => toConversation( - await collectionRequest(base, { method: 'POST', body: titleFor(title) }, onAuthError) + await collectionRequest(base, { method: 'POST', body: titleFor(title), signal }, onAuthError) ), - deleteConversation: async conversationId => { - await requestJson(`${base}/${encodeURIComponent(conversationId)}`, { method: 'DELETE' }, onAuthError); + deleteConversation: async (conversationId, signal) => { + await requestJson(`${base}/${encodeURIComponent(conversationId)}`, { method: 'DELETE', signal }, onAuthError); }, - getMessages: async (conversationId, before): Promise => { + getMessages: async (conversationId, before, signal): Promise => { const cursor = before ? `&cursor=${encodeURIComponent(before)}` : ''; const page = await requestJson( `${base}/${encodeURIComponent(conversationId)}/messages?limit=${HISTORY_PAGE_SIZE}${cursor}`, - {}, + { signal }, onAuthError ); // The server pages from the latest message backwards; the panel renders oldest to newest. @@ -356,44 +359,56 @@ export const createAssistantTransport = ( }; }; -/** - * Guards a per-note transport. Deactivation aborts open runs and rejects calls while inactive. - * The transport can be reactivated unless its session signal has aborted. - */ +/** A one-shot per-note scope. Reopening a note creates a new scope. */ export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortSignal) => { - let active = true; - const runs = new Set(); + const scope = new AbortController(); + const stop = () => scope.abort(); + if (sessionSignal?.aborted) stop(); + else sessionSignal?.addEventListener('abort', stop, { once: true }); const assertActive = () => { - if (!active || sessionSignal?.aborted) { + if (scope.signal.aborted) { throw new DOMException('Notebook changed', 'AbortError'); } }; - const guarded = async (request: () => Promise): Promise => { + const guarded = async (request: (signal: AbortSignal) => Promise, callerSignal?: AbortSignal): Promise => { assertActive(); + const controller = callerSignal ? new AbortController() : undefined; + const abort = () => controller?.abort(); + if (controller) { + scope.signal.addEventListener('abort', abort, { once: true }); + callerSignal?.addEventListener('abort', abort, { once: true }); + if (scope.signal.aborted || callerSignal?.aborted) abort(); + } + const signal = controller?.signal ?? scope.signal; try { - const result = await request(); + signal.throwIfAborted(); + const result = await request(signal); assertActive(); + signal.throwIfAborted(); return result; } catch (error) { assertActive(); + signal.throwIfAborted(); throw error; + } finally { + scope.signal.removeEventListener('abort', abort); + callerSignal?.removeEventListener('abort', abort); } }; const transport: AssistantTransport = { - listConversations: () => guarded(() => inner.listConversations()), - createConversation: body => guarded(() => inner.createConversation(body)), - deleteConversation: id => guarded(() => inner.deleteConversation(id)), - getMessages: (id, before) => guarded(() => inner.getMessages(id, before)), + listConversations: signal => guarded(requestSignal => inner.listConversations(requestSignal), signal), + createConversation: (body, signal) => guarded(requestSignal => inner.createConversation(body, requestSignal), signal), + deleteConversation: (id, signal) => guarded(requestSignal => inner.deleteConversation(id, requestSignal), signal), + getMessages: (id, before, signal) => guarded(requestSignal => inner.getMessages(id, before, requestSignal), signal), async *openRun(id, body, signal) { assertActive(); const controller = new AbortController(); const abort = () => controller.abort(); signal.addEventListener('abort', abort, { once: true }); - sessionSignal?.addEventListener('abort', abort, { once: true }); + scope.signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) { abort(); } - runs.add(controller); try { for await (const event of inner.openRun(id, body, controller.signal)) { assertActive(); @@ -408,22 +423,18 @@ export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortS } finally { controller.abort(); signal.removeEventListener('abort', abort); - sessionSignal?.removeEventListener('abort', abort); - runs.delete(controller); + scope.signal.removeEventListener('abort', abort); } } }; return { get active() { - return active && !sessionSignal?.aborted; + return !scope.signal.aborted; }, transport, - setActive(next: boolean) { - active = next; - if (!next) { - runs.forEach(controller => controller.abort()); - runs.clear(); - } + deactivate() { + stop(); + sessionSignal?.removeEventListener('abort', stop); } }; }; From 34034fc041c241130efff8f43fbc408ab7bc37ed Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sat, 17 Oct 2026 22:59:19 +0900 Subject: [PATCH 14/16] [ZEPPELIN-6742] Preserve conversation titles in transport requests --- .../model/assistantTransport.spec.ts | 20 +++++++++++++++++-- .../assistant/model/assistantTransport.ts | 20 +++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) 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 index 4d2f3715cc6..96bd06fae7a 100644 --- 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 @@ -186,11 +186,27 @@ describe('assistant transport', () => { expect(fetchMock.mock.calls[1][1]).toMatchObject({ credentials: 'include', headers: expect.objectContaining({ 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json' }), - // The first question names the conversation, with whitespace collapsed. - body: JSON.stringify({ title: 'Load the data' }) + 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', 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 index d4cd709cd2a..f11bae9c125 100644 --- 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 @@ -314,11 +314,6 @@ export const createAssistantTransport = ( updatedAt: conversation.updatedAt }; }; - // The server names an untitled conversation by its creation time. - const titleFor = (title?: string) => { - const trimmed = title?.trim().replace(/\s+/g, ' '); - return trimmed ? { title: trimmed.length > 80 ? `${trimmed.slice(0, 79)}…` : trimmed } : {}; - }; return { // Latest activity first, so the panel opens on it. listConversations: async signal => @@ -331,10 +326,18 @@ export const createAssistantTransport = ( .map(({ conversation }) => toConversation(conversation)), createConversation: async ({ title } = {}, signal) => toConversation( - await collectionRequest(base, { method: 'POST', body: titleFor(title), signal }, onAuthError) + await collectionRequest( + base, + { method: 'POST', body: title === undefined ? {} : { title }, signal }, + onAuthError + ) ), deleteConversation: async (conversationId, signal) => { - await requestJson(`${base}/${encodeURIComponent(conversationId)}`, { method: 'DELETE', signal }, onAuthError); + await requestJson( + `${base}/${encodeURIComponent(conversationId)}`, + { method: 'DELETE', signal }, + onAuthError + ); }, getMessages: async (conversationId, before, signal): Promise => { const cursor = before ? `&cursor=${encodeURIComponent(before)}` : ''; @@ -397,7 +400,8 @@ export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortS }; const transport: AssistantTransport = { listConversations: signal => guarded(requestSignal => inner.listConversations(requestSignal), signal), - createConversation: (body, signal) => guarded(requestSignal => inner.createConversation(body, requestSignal), signal), + createConversation: (body, signal) => + guarded(requestSignal => inner.createConversation(body, requestSignal), signal), deleteConversation: (id, signal) => guarded(requestSignal => inner.deleteConversation(id, requestSignal), signal), getMessages: (id, before, signal) => guarded(requestSignal => inner.getMessages(id, before, requestSignal), signal), async *openRun(id, body, signal) { From 765c90514eb5e1a0596c42e40eed37cd3380ebb2 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 18 Oct 2026 12:06:02 +0900 Subject: [PATCH 15/16] [ZEPPELIN-6742] Separate Assistant REST API from socket runs --- .../assistant/model/assistantContract.ts | 17 +- .../model/assistantTransport.spec.ts | 152 +----------------- .../assistant/model/assistantTransport.ts | 130 ++------------- 3 files changed, 27 insertions(+), 272 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 84ba0574bb1..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,15 +57,13 @@ export type AssistantRunEvent = | { type: 'tool_call.started'; toolCallId: string; name: string } | { type: 'tool_call.done'; toolCallId: string }; -export interface AssistantTransport { - /** Conversations of the note the transport was created for. */ - listConversations(signal?: AbortSignal): Promise; - createConversation(body: { title?: string }, signal?: AbortSignal): Promise; - deleteConversation(conversationId: string, signal?: AbortSignal): Promise; +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, signal?: AbortSignal): Promise; - /** Aborting stops local event consumption; it does not cancel a run already accepted by the server. */ - openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable; + getMessages(conversationId: string, before?: string): Promise; } 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 index 96bd06fae7a..3b5e0355e80 100644 --- 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 @@ -16,9 +16,8 @@ import type { AssistantSendMessage, AssistantSocketEvent } from '@zeppelin/sdk'; import { AssistantHttpError, - createAssistantTransport, + createAssistantConversationApi, mapSocketEvent, - scopeTransport, socketRun, toVisibleMessages } from './assistantTransport'; @@ -26,7 +25,6 @@ import { AssistantConnectionError, AssistantStreamError, type AssistantAnswerMessage, - type AssistantConversation, type AssistantRunEvent } from './assistantContract'; import { answerText, answerToolCalls, joinPages } from './messageHistory'; @@ -57,7 +55,7 @@ const fakeSocket = () => { }; const forNote = (noteId: string, onAuthError?: (status: number) => void) => - createAssistantTransport('https://example.test/zeppelin/api', noteId, fakeSocket(), onAuthError); + createAssistantConversationApi('https://example.test/zeppelin/api', noteId, onAuthError); const iterate = (iterable: AsyncIterable): AsyncIterator => iterable[Symbol.asyncIterator](); @@ -238,28 +236,11 @@ describe('assistant transport', () => { expect(onAuthError.mock.calls).toEqual([[401], [405]]); }); - it('does not redirect for an auth response that arrives after the note scope ends', async () => { - let resolveFetch: (response: Response) => void = () => undefined; - const fetch = vi.fn( - (_url: string, _init: RequestInit) => new Promise(resolve => (resolveFetch = resolve)) - ); - vi.stubGlobal('fetch', fetch); - const onAuthError = vi.fn(); - const scoped = scopeTransport(forNote('n', onAuthError)); - const request = scoped.transport.listConversations(); - const signal = fetch.mock.calls[0][1].signal as AbortSignal; - - scoped.deactivate(); - expect(signal.aborted).toBe(true); - resolveFetch(new Response(null, { status: 401 })); - await expect(request).rejects.toMatchObject({ name: 'AbortError' }); - expect(onAuthError).not.toHaveBeenCalled(); - }); - it('sends over the socket and yields only this conversation until a terminal event', async () => { const socket = fakeSocket(); - const transport = createAssistantTransport('https://example.test/api', 'n', socket); - const run = iterate(transport.openRun('c1', { content: 'improve' }, new AbortController().signal)); + 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' }); @@ -509,127 +490,6 @@ describe('assistant transport', () => { ).toBeUndefined(); }); - it('drops late responses and aborts runs once a scoped transport is deactivated', async () => { - let resolveList: (conversations: []) => void = () => undefined; - let runSignal: AbortSignal | undefined; - const inner = { - listConversations: vi.fn(() => new Promise<[]>(resolve => (resolveList = resolve))), - createConversation: vi.fn(), - deleteConversation: vi.fn(), - getMessages: vi.fn(), - openRun: async function* (_id: string, _body: unknown, signal: AbortSignal): AsyncIterable { - runSignal = signal; - yield { type: 'run.started' }; - await new Promise(() => undefined); - } - }; - const scoped = scopeTransport(inner); - const list = scoped.transport.listConversations(); - const run = iterate(scoped.transport.openRun('t', { content: 'hi' }, new AbortController().signal)); - await run.next(); - expect(inner.listConversations).toHaveBeenCalledWith(expect.any(AbortSignal)); - - scoped.deactivate(); - resolveList([]); - - await expect(list).rejects.toMatchObject({ name: 'AbortError' }); - expect(runSignal?.aborted).toBe(true); - await expect(scoped.transport.getMessages('t')).rejects.toMatchObject({ name: 'AbortError' }); - }); - - it('retires an old REST request permanently while a fresh note scope can read', async () => { - let resolveOld: (conversations: []) => void = () => undefined; - const inner = { - listConversations: vi - .fn() - .mockImplementationOnce(() => new Promise<[]>(resolve => (resolveOld = resolve))) - .mockResolvedValueOnce([]), - createConversation: vi.fn(), - deleteConversation: vi.fn(), - getMessages: vi.fn(), - openRun: vi.fn() - }; - const oldScope = scopeTransport(inner); - const oldRead = oldScope.transport.listConversations(); - const oldSignal = inner.listConversations.mock.calls[0][0] as AbortSignal; - - oldScope.deactivate(); - expect(oldSignal.aborted).toBe(true); - const newScope = scopeTransport(inner); - await expect(newScope.transport.listConversations()).resolves.toEqual([]); - resolveOld([]); - await expect(oldRead).rejects.toMatchObject({ name: 'AbortError' }); - expect(oldScope.active).toBe(false); - }); - - it('passes a caller abort to REST without retiring the note scope', async () => { - let resolveRead: (conversations: []) => void = () => undefined; - const inner = { - listConversations: vi - .fn() - .mockImplementationOnce(() => new Promise<[]>(resolve => (resolveRead = resolve))) - .mockResolvedValueOnce([]), - createConversation: vi.fn(), - deleteConversation: vi.fn(), - getMessages: vi.fn(), - openRun: vi.fn() - }; - const scoped = scopeTransport(inner); - const caller = new AbortController(); - const read = scoped.transport.listConversations(caller.signal); - const requestSignal = inner.listConversations.mock.calls[0][0] as AbortSignal; - - caller.abort(); - expect(requestSignal.aborted).toBe(true); - resolveRead([]); - await expect(read).rejects.toMatchObject({ name: 'AbortError' }); - expect(scoped.active).toBe(true); - await expect(scoped.transport.listConversations()).resolves.toEqual([]); - }); - - it('retires pending creations and active runs when the host session aborts', async () => { - const session = new AbortController(); - let resolveCreate: (value: AssistantConversation) => void = () => undefined; - let runSignal: AbortSignal | undefined; - const inner = { - listConversations: vi.fn().mockResolvedValue([]), - createConversation: vi.fn( - () => - new Promise(resolve => { - resolveCreate = resolve; - }) - ), - deleteConversation: vi.fn(), - getMessages: vi.fn(), - openRun: async function* (_id: string, _body: unknown, signal: AbortSignal): AsyncIterable { - runSignal = signal; - yield { type: 'run.started' }; - await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true })); - yield { type: 'run.completed' }; - } - }; - const scoped = scopeTransport(inner, session.signal); - const creation = scoped.transport.createConversation({}); - const run = iterate(scoped.transport.openRun('c1', { content: 'hi' }, new AbortController().signal)); - await run.next(); - const terminal = run.next(); - session.abort(); - resolveCreate({ - id: 'old-account-conversation', - ownerId: 'alice', - canSendMessage: true, - updatedAt: '2026-10-01T00:00:00Z' - }); - await expect(creation).rejects.toMatchObject({ name: 'AbortError' }); - await expect(terminal).rejects.toMatchObject({ name: 'AbortError' }); - expect(runSignal?.aborted).toBe(true); - expect(scoped.active).toBe(false); - scoped.deactivate(); - expect(scoped.active).toBe(false); - await expect(scoped.transport.listConversations()).rejects.toMatchObject({ name: 'AbortError' }); - expect(inner.listConversations).not.toHaveBeenCalled(); - }); - it('shows one bubble per turn and hides tool and system messages', () => { expect( toVisibleMessages([ @@ -788,7 +648,7 @@ describe('conversation list', () => { }; const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ body: [conversation] }))); vi.stubGlobal('fetch', fetch); - const transport = createAssistantTransport('/api', 'note', fakeSocket()); + 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 index f11bae9c125..f2d66c1d6fe 100644 --- 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 @@ -15,11 +15,11 @@ import { AssistantConnectionError, AssistantStreamError, type AssistantConversation, + type AssistantConversationApi, type AssistantMessage, type AssistantMessagePage, type AssistantRunEvent, - type AssistantToolCallRef, - type AssistantTransport + type AssistantToolCallRef } from './assistantContract'; import { mergeAnswers } from './messageHistory'; @@ -171,13 +171,11 @@ const httpError = (response: Response, onAuthError?: AssistantAuthErrorHandler): const requestJson = async ( url: string, - init: { method?: string; body?: unknown; signal?: AbortSignal } = {}, + init: { method?: string; body?: unknown } = {}, onAuthError?: AssistantAuthErrorHandler ): Promise => { - init.signal?.throwIfAborted(); const response = await fetch(url, { method: init.method ?? 'GET', - ...(init.signal ? { signal: init.signal } : {}), credentials: 'include', headers: init.body === undefined @@ -185,12 +183,10 @@ const requestJson = async ( : { ...requestHeaders, Accept: 'application/json', 'Content-Type': 'application/json' }, body: init.body === undefined ? undefined : JSON.stringify(init.body) }); - init.signal?.throwIfAborted(); if (!response.ok) { throw httpError(response, onAuthError); } const text = await response.text(); - init.signal?.throwIfAborted(); return (text ? unwrapBody(JSON.parse(text)) : undefined) as T; }; @@ -219,6 +215,7 @@ interface RunSlot { /** * 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, @@ -295,15 +292,13 @@ type MessagePage = { messages: ConversationMessage[]; cursor?: string | null }; const HISTORY_PAGE_SIZE = 50; /** - * Transport for the per-note conversation API. REST for conversations and history (`apiBase` is the host's - * REST base, e.g. `.../api`); sending goes over the host's notebook WebSocket. + * REST client for a note's conversations and history. `apiBase` is the host's REST base, e.g. `.../api`. */ -export const createAssistantTransport = ( +export const createAssistantConversationApi = ( apiBase: string, noteId: string, - socket: AssistantSocket, onAuthError?: AssistantAuthErrorHandler -): AssistantTransport => { +): AssistantConversationApi => { const base = `${apiBase.replace(/\/$/, '')}/notes/${encodeURIComponent(noteId)}/conversations`; const toConversation = (conversation: ConversationSummary): AssistantConversation => { return { @@ -316,129 +311,34 @@ export const createAssistantTransport = ( }; return { // Latest activity first, so the panel opens on it. - listConversations: async signal => - (await collectionRequest(base, { signal }, onAuthError)) + listConversations: async () => + (await collectionRequest(base, {}, onAuthError)) .map(conversation => ({ conversation, time: Date.parse(conversation.updatedAt) || 0 })) .sort((a, b) => b.time - a.time) .map(({ conversation }) => toConversation(conversation)), - createConversation: async ({ title } = {}, signal) => + createConversation: async ({ title } = {}) => toConversation( await collectionRequest( base, - { method: 'POST', body: title === undefined ? {} : { title }, signal }, + { method: 'POST', body: title === undefined ? {} : { title } }, onAuthError ) ), - deleteConversation: async (conversationId, signal) => { - await requestJson( - `${base}/${encodeURIComponent(conversationId)}`, - { method: 'DELETE', signal }, - onAuthError - ); + deleteConversation: async conversationId => { + await requestJson(`${base}/${encodeURIComponent(conversationId)}`, { method: 'DELETE' }, onAuthError); }, - getMessages: async (conversationId, before, signal): Promise => { + getMessages: async (conversationId, before): Promise => { const cursor = before ? `&cursor=${encodeURIComponent(before)}` : ''; const page = await requestJson( `${base}/${encodeURIComponent(conversationId)}/messages?limit=${HISTORY_PAGE_SIZE}${cursor}`, - { signal }, + {}, 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 }; - }, - openRun: (conversationId, body, signal) => - socketRun( - socket, - { - noteId, - conversationId, - content: body.content - }, - signal - ) - }; -}; - -/** A one-shot per-note scope. Reopening a note creates a new scope. */ -export const scopeTransport = (inner: AssistantTransport, sessionSignal?: AbortSignal) => { - const scope = new AbortController(); - const stop = () => scope.abort(); - if (sessionSignal?.aborted) stop(); - else sessionSignal?.addEventListener('abort', stop, { once: true }); - const assertActive = () => { - if (scope.signal.aborted) { - throw new DOMException('Notebook changed', 'AbortError'); - } - }; - const guarded = async (request: (signal: AbortSignal) => Promise, callerSignal?: AbortSignal): Promise => { - assertActive(); - const controller = callerSignal ? new AbortController() : undefined; - const abort = () => controller?.abort(); - if (controller) { - scope.signal.addEventListener('abort', abort, { once: true }); - callerSignal?.addEventListener('abort', abort, { once: true }); - if (scope.signal.aborted || callerSignal?.aborted) abort(); - } - const signal = controller?.signal ?? scope.signal; - try { - signal.throwIfAborted(); - const result = await request(signal); - assertActive(); - signal.throwIfAborted(); - return result; - } catch (error) { - assertActive(); - signal.throwIfAborted(); - throw error; - } finally { - scope.signal.removeEventListener('abort', abort); - callerSignal?.removeEventListener('abort', abort); - } - }; - const transport: AssistantTransport = { - listConversations: signal => guarded(requestSignal => inner.listConversations(requestSignal), signal), - createConversation: (body, signal) => - guarded(requestSignal => inner.createConversation(body, requestSignal), signal), - deleteConversation: (id, signal) => guarded(requestSignal => inner.deleteConversation(id, requestSignal), signal), - getMessages: (id, before, signal) => guarded(requestSignal => inner.getMessages(id, before, requestSignal), signal), - async *openRun(id, body, signal) { - assertActive(); - const controller = new AbortController(); - const abort = () => controller.abort(); - signal.addEventListener('abort', abort, { once: true }); - scope.signal.addEventListener('abort', abort, { once: true }); - if (signal.aborted) { - abort(); - } - try { - for await (const event of inner.openRun(id, body, controller.signal)) { - assertActive(); - if (controller.signal.aborted) { - throw new DOMException('Run stopped', 'AbortError'); - } - yield event; - } - } catch (error) { - assertActive(); - throw error; - } finally { - controller.abort(); - signal.removeEventListener('abort', abort); - scope.signal.removeEventListener('abort', abort); - } - } - }; - return { - get active() { - return !scope.signal.aborted; - }, - transport, - deactivate() { - stop(); - sessionSignal?.removeEventListener('abort', stop); } }; }; From 4fedd2754d81f86372bbc4c242fea0eaa2e91485 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 18 Oct 2026 12:06:30 +0900 Subject: [PATCH 16/16] [ZEPPELIN-6742] Clarify Assistant conversation response mapping --- .../assistant/model/assistantTransport.spec.ts | 3 +++ .../entities/assistant/model/assistantTransport.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) 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 index 3b5e0355e80..e1e763c3a94 100644 --- 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 @@ -113,6 +113,7 @@ describe('assistant transport', () => { id: 'conv-1', title: 'First', noteId: 'note/one', + createdAt: '2026-09-30T00:00:00Z', ownerId: 'alice', canSendMessage: false, updatedAt: '2026-10-01T00:00:00Z' @@ -127,8 +128,10 @@ describe('assistant transport', () => { 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' } }, 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 index f2d66c1d6fe..ef6f0951a6b 100644 --- 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 @@ -279,11 +279,13 @@ export async function* socketRun( } } -type ConversationSummary = { +type ConversationMetadataResponse = { id: string; + noteId: string; title?: string; ownerId: string; canSendMessage: boolean; + createdAt: string; updatedAt: string; }; type MessagePage = { messages: ConversationMessage[]; cursor?: string | null }; @@ -300,7 +302,7 @@ export const createAssistantConversationApi = ( onAuthError?: AssistantAuthErrorHandler ): AssistantConversationApi => { const base = `${apiBase.replace(/\/$/, '')}/notes/${encodeURIComponent(noteId)}/conversations`; - const toConversation = (conversation: ConversationSummary): AssistantConversation => { + const toAssistantConversation = (conversation: ConversationMetadataResponse): AssistantConversation => { return { id: conversation.id, title: conversation.title, @@ -312,16 +314,16 @@ export const createAssistantConversationApi = ( return { // Latest activity first, so the panel opens on it. listConversations: async () => - (await collectionRequest(base, {}, onAuthError)) + (await collectionRequest(base, {}, onAuthError)) .map(conversation => ({ conversation, time: Date.parse(conversation.updatedAt) || 0 })) .sort((a, b) => b.time - a.time) - .map(({ conversation }) => toConversation(conversation)), + .map(({ conversation }) => toAssistantConversation(conversation)), createConversation: async ({ title } = {}) => - toConversation( - await collectionRequest( + toAssistantConversation( + await collectionRequest( base, { method: 'POST', body: title === undefined ? {} : { title } }, onAuthError