From aa2c462221da3860384013e23b5f5ad6e68c400d Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Thu, 8 Oct 2026 16:17:01 +0900 Subject: [PATCH 01/10] Define shared assistant UI contracts --- .../assistant/model/assistantContract.ts | 84 +++++++++++++++++++ .../assistant/model/messageHistory.spec.ts | 63 ++++++++++++++ .../assistant/model/messageHistory.ts | 37 ++++++++ .../projects/zeppelin-sdk/src/assistant-ui.ts | 23 +++++ .../projects/zeppelin-sdk/src/public-api.ts | 2 + 5 files changed, 209 insertions(+) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts 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 new file mode 100644 index 00000000000..588b4293dbf --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts @@ -0,0 +1,84 @@ +/* + * 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. + */ + +/** + * Data and operations the panel needs to display conversations and run replies. + * Implementations can use a server connection or local fixtures. + */ +export interface AssistantConversation { + id: string; + title?: string; + /** Account that created the conversation, shown as "Started by …". */ + ownerId?: string; + /** Whether the requesting user can send messages to the conversation, as the server decides. */ + canSendMessage?: boolean; + /** Last activity (ISO time), shown in the conversation list. */ + updatedAt?: string; +} +export interface AssistantMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + /** From history: the tools the server called before this answer. Live runs report them as events instead. */ + toolCalls?: AssistantToolCallRef[]; +} +export interface AssistantToolCallRef { + id: string; + name: string; +} +/** One page of a conversation's history, oldest first. */ +export interface AssistantMessagePage { + messages: AssistantMessage[]; + /** Pass as `before` to load the page before this one; null when this page starts the conversation. */ + earlierCursor: string | null; +} +export interface AssistantRunBody { + prompt: string; +} +export type AssistantRunEvent = + | { type: 'run.started'; runId?: string } + | { type: 'run.completed'; runId?: string } + | { type: 'run.failed'; runId?: string; message: string } + | { type: 'message.delta'; messageId: string; delta: string } + | { type: 'message.done'; messageId: string; content: string } + | { type: 'tool_call.started'; toolCallId: string; name: string } + // The server does not repeat the tool name on completion. + | { type: 'tool_call.done'; toolCallId: string; name?: string }; + +export type AssistantRunState = 'idle' | 'running' | 'disconnected'; + +export interface AssistantTransport { + /** Conversations of the note the transport was created for. */ + listConversations(): Promise; + createConversation(body: { title?: string }): Promise; + deleteConversation(conversationId: string): Promise; + /** The latest page of history, or the page before `before` (an `earlierCursor`). */ + getMessages(conversationId: string, before?: string): Promise; + /** Observe a retained request, including lost connections while its server status is being recovered. */ + subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void; + openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable; +} + +export class AssistantStreamError extends Error { + constructor() { + super('The assistant stopped responding before the reply finished. Retry the request.'); + this.name = 'AssistantStreamError'; + } +} + +/** The notebook WebSocket closed during a run; its events went with it, so only a reload shows where it got to. */ +export class AssistantConnectionError extends Error { + constructor() { + super('The connection to the server was lost. Retry to reload the conversation and see where the answer got to.'); + this.name = 'AssistantConnectionError'; + } +} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts new file mode 100644 index 00000000000..f93216f700b --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.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 { describe, expect, it } from 'vitest'; + +import type { AssistantMessage } from './assistantContract'; +import { joinPages } from './messageHistory'; + +describe('joinPages', () => { + it('joins a reply split across pages under the later id and retains both action logs', () => { + const earlier: AssistantMessage[] = [ + { id: 'u1', role: 'user', content: 'Read this notebook' }, + { id: 'a1', role: 'assistant', content: 'Looking.', toolCalls: [{ id: 'c1', name: 'read' }] } + ]; + const later: AssistantMessage[] = [ + { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [{ id: 'c2', name: 'summarize' }] } + ]; + expect(joinPages(earlier, later)).toEqual([ + earlier[0], + { + id: 'a2', + role: 'assistant', + content: 'Looking.\n\nDone.', + toolCalls: [ + { id: 'c1', name: 'read' }, + { id: 'c2', name: 'summarize' } + ] + } + ]); + expect(earlier[1].content).toBe('Looking.'); + expect(later[0].content).toBe('Done.'); + }); + + it('retains a tool-only reply across an empty intermediate page and deduplicates repeated calls', () => { + const call = { id: 'c1', name: 'read' }; + const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', content: '', toolCalls: [call] }]; + const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call] }]; + expect(joinPages(joinPages(earlier, []), later)).toEqual([ + { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call] } + ]); + }); + + it('keeps separate turns apart and handles empty history', () => { + const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', content: 'First answer' }]; + const later: AssistantMessage[] = [ + { id: 'u2', role: 'user', content: 'Another question' }, + { id: 'a2', role: 'assistant', content: 'Another answer' } + ]; + expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); + expect(joinPages([], later)).toEqual(later); + expect(joinPages(earlier, [])).toEqual(earlier); + expect(joinPages([], [])).toEqual([]); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts new file mode 100644 index 00000000000..dce36c3f62e --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts @@ -0,0 +1,37 @@ +/* + * 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 { AssistantMessage } from './assistantContract'; + +export const mergeAnswers = (earlier: AssistantMessage, later: AssistantMessage): AssistantMessage => { + const toolCalls = Array.from( + new Map([...(earlier.toolCalls ?? []), ...(later.toolCalls ?? [])].map(call => [call.id, call])).values() + ); + return { + ...later, + content: [earlier.content, later.content].filter(Boolean).join('\n\n'), + ...(toolCalls.length ? { toolCalls } : {}) + }; +}; + +/** + * Puts an earlier page in front of the loaded history. A turn split across the page boundary + * becomes one bubble again, under the later id. + */ +export const joinPages = (earlier: AssistantMessage[], later: AssistantMessage[]): AssistantMessage[] => { + const last = earlier[earlier.length - 1]; + const first = later[0]; + if (last?.role === 'assistant' && first?.role === 'assistant') { + return [...earlier.slice(0, -1), mergeAnswers(last, first), ...later.slice(1)]; + } + return [...earlier, ...later]; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts new file mode 100644 index 00000000000..8b10321d82a --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** The notebook sidebar's width: its default and its resize range. The assistant panel shares it. */ +export const NOTEBOOK_SIDEBAR_WIDTH = { initial: 370, min: 280, max: 800 } as const; + +/** Result of a host reveal: scrolled to it, already visible, or not rendered. */ +export type AssistantRevealResult = 'shown' | 'visible' | 'missing'; + +/** A notebook paragraph as the remote sees it: enough to label and link it. */ +export interface AssistantParagraphRef { + id: string; + title?: string; +} 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 ba14a13c7ec..dcf432e8446 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,8 @@ */ export * from './angular-object'; +export * from './assistant-ui'; export * from './interfaces/public-api'; export * from './message'; + export * from './paragraph-state'; From 87c86da899bf1cd562637c94bc0e023578ae26c1 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 9 Oct 2026 11:14:37 +0900 Subject: [PATCH 02/10] Remove misplaced Assistant contract comment --- .../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 588b4293dbf..adebdcdc79c 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 @@ -10,10 +10,6 @@ * limitations under the License. */ -/** - * Data and operations the panel needs to display conversations and run replies. - * Implementations can use a server connection or local fixtures. - */ export interface AssistantConversation { id: string; title?: string; From 37262370465ed2e8c1aadad6dd33ac07143943b4 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 9 Oct 2026 19:14:58 +0900 Subject: [PATCH 03/10] Clarify Assistant run-state contract comment --- .../src/entities/assistant/model/assistantContract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 adebdcdc79c..c0f2d6dd79d 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 @@ -59,7 +59,7 @@ export interface AssistantTransport { deleteConversation(conversationId: string): Promise; /** The latest page of history, or the page before `before` (an `earlierCursor`). */ getMessages(conversationId: string, before?: string): Promise; - /** Observe a retained request, including lost connections while its server status is being recovered. */ + /** Observe local run state, including a lost connection until history is reloaded. */ subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void; openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable; } From 51fac8db998ad6409316fe7b74f84af1ce7a9efd Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 11 Oct 2026 13:26:39 +0900 Subject: [PATCH 04/10] Define Assistant slot in the shared UI contract Give the sidebar shell and transport one SDK type to consume without duplicating its declaration in their separate PRs. Confidence: high Scope-risk: requires dependent PR branches to synchronize Not-tested: standalone contracts build in this temporary worktree --- .../projects/zeppelin-sdk/src/assistant-ui.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts index 8b10321d82a..837d748478a 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts @@ -21,3 +21,9 @@ export interface AssistantParagraphRef { id: string; title?: string; } + +/** Notebook DOM targets for the React navigation button and panel. */ +export interface AssistantSlot { + element: HTMLElement; + kind: 'navigation' | 'panel'; +} From 6e44f7b71467140803ab7c7920c209950b2f3a44 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Thu, 15 Oct 2026 23:30:00 +0900 Subject: [PATCH 05/10] Preserve Assistant history round order --- .../assistant/model/assistantContract.ts | 23 ++++++-- .../assistant/model/messageHistory.spec.ts | 58 ++++++++++++++++--- .../assistant/model/messageHistory.ts | 20 +++++-- .../projects/zeppelin-sdk/src/public-api.ts | 1 - 4 files changed, 83 insertions(+), 19 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 c0f2d6dd79d..587a95cdd4f 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 @@ -20,17 +20,31 @@ export interface AssistantConversation { /** Last activity (ISO time), shown in the conversation list. */ updatedAt?: string; } -export interface AssistantMessage { +export type AssistantMessage = AssistantUserMessage | AssistantAnswerMessage; +export interface AssistantUserMessage { id: string; - role: 'user' | 'assistant'; + role: 'user'; + content: string; +} +export interface AssistantAnswerMessage { + /** The last assistant round's id, used as the bubble key. */ + id: string; + role: 'assistant'; + /** Current UI projections; render segments to show text and actions in their original order. */ content: string; - /** From history: the tools the server called before this answer. Live runs report them as events instead. */ toolCalls?: AssistantToolCallRef[]; + /** Ordered assistant rounds as stored by the server, including tool-only rounds. */ + segments: AssistantMessageSegment[]; } export interface AssistantToolCallRef { id: string; name: string; } +export interface AssistantMessageSegment { + id: string; + content: string; + toolCalls?: AssistantToolCallRef[]; +} /** One page of a conversation's history, oldest first. */ export interface AssistantMessagePage { messages: AssistantMessage[]; @@ -47,8 +61,7 @@ export type AssistantRunEvent = | { type: 'message.delta'; messageId: string; delta: string } | { type: 'message.done'; messageId: string; content: string } | { type: 'tool_call.started'; toolCallId: string; name: string } - // The server does not repeat the tool name on completion. - | { type: 'tool_call.done'; toolCallId: string; name?: string }; + | { type: 'tool_call.done'; toolCallId: string }; export type AssistantRunState = 'idle' | 'running' | 'disconnected'; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts index f93216f700b..155b3417c31 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts @@ -17,12 +17,14 @@ import { joinPages } from './messageHistory'; describe('joinPages', () => { it('joins a reply split across pages under the later id and retains both action logs', () => { + const first = { id: 'a1', content: 'Looking.', toolCalls: [{ id: 'c1', name: 'read' }] }; + const second = { id: 'a2', content: 'Done.', toolCalls: [{ id: 'c2', name: 'summarize' }] }; const earlier: AssistantMessage[] = [ { id: 'u1', role: 'user', content: 'Read this notebook' }, - { id: 'a1', role: 'assistant', content: 'Looking.', toolCalls: [{ id: 'c1', name: 'read' }] } + { id: 'a1', role: 'assistant', content: first.content, toolCalls: first.toolCalls, segments: [first] } ]; const later: AssistantMessage[] = [ - { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [{ id: 'c2', name: 'summarize' }] } + { id: 'a2', role: 'assistant', content: second.content, toolCalls: second.toolCalls, segments: [second] } ]; expect(joinPages(earlier, later)).toEqual([ earlier[0], @@ -33,7 +35,8 @@ describe('joinPages', () => { toolCalls: [ { id: 'c1', name: 'read' }, { id: 'c2', name: 'summarize' } - ] + ], + segments: [first, second] } ]); expect(earlier[1].content).toBe('Looking.'); @@ -42,22 +45,61 @@ describe('joinPages', () => { it('retains a tool-only reply across an empty intermediate page and deduplicates repeated calls', () => { const call = { id: 'c1', name: 'read' }; - const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', content: '', toolCalls: [call] }]; - const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call] }]; + const first = { id: 'a1', content: '', toolCalls: [call] }; + const second = { id: 'a2', content: 'Done.', toolCalls: [call] }; + const earlier: AssistantMessage[] = [ + { id: 'a1', role: 'assistant', content: '', toolCalls: [call], segments: [first] } + ]; + const later: AssistantMessage[] = [ + { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call], segments: [second] } + ]; expect(joinPages(joinPages(earlier, []), later)).toEqual([ - { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call] } + { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call], segments: [first, second] } + ]); + }); + + it('keeps interleaved text and tool rounds in server order across an overlapping page boundary', () => { + const rounds = [ + { id: 'a1', content: 'First thought.', toolCalls: [{ id: 'c1', name: 'read' }] }, + { id: 'a2', content: 'Second thought.', toolCalls: [{ id: 'c2', name: 'summarize' }] }, + { id: 'a3', content: 'Final answer.' } + ]; + const earlier: AssistantMessage[] = [ + { id: 'a2', role: 'assistant', content: 'First thought.\n\nSecond thought.', segments: rounds.slice(0, 2) } + ]; + const later: AssistantMessage[] = [ + { id: 'a3', role: 'assistant', content: 'Second thought.\n\nFinal answer.', segments: rounds.slice(1) } + ]; + expect(joinPages(earlier, later)).toEqual([ + { + id: 'a3', + role: 'assistant', + content: 'First thought.\n\nSecond thought.\n\nFinal answer.', + toolCalls: [rounds[0].toolCalls![0], rounds[1].toolCalls![0]], + segments: rounds + } ]); }); it('keeps separate turns apart and handles empty history', () => { - const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', content: 'First answer' }]; + const earlier: AssistantMessage[] = [ + { id: 'a1', role: 'assistant', content: 'First answer', segments: [{ id: 'a1', content: 'First answer' }] } + ]; const later: AssistantMessage[] = [ { id: 'u2', role: 'user', content: 'Another question' }, - { id: 'a2', role: 'assistant', content: 'Another answer' } + { id: 'a2', role: 'assistant', content: 'Another answer', segments: [{ id: 'a2', content: 'Another answer' }] } ]; expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); expect(joinPages([], later)).toEqual(later); expect(joinPages(earlier, [])).toEqual(earlier); expect(joinPages([], [])).toEqual([]); }); + + it('does not merge an answer with the next question across pages', () => { + const earlier: AssistantMessage[] = [{ id: 'u1', role: 'user', content: 'Question' }]; + const later: AssistantMessage[] = [ + { id: 'a1', role: 'assistant', content: 'Answer', segments: [{ id: 'a1', content: 'Answer' }] } + ]; + expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); + }); }); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts index dce36c3f62e..1e637cc4cd5 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts @@ -10,16 +10,26 @@ * limitations under the License. */ -import type { AssistantMessage } from './assistantContract'; +import type { AssistantAnswerMessage, AssistantMessage } from './assistantContract'; -export const mergeAnswers = (earlier: AssistantMessage, later: AssistantMessage): AssistantMessage => { +export const mergeAnswers = ( + earlier: AssistantAnswerMessage, + later: AssistantAnswerMessage +): AssistantAnswerMessage => { + const segments = Array.from( + new Map([...earlier.segments, ...later.segments].map(segment => [segment.id, segment])).values() + ); const toolCalls = Array.from( - new Map([...(earlier.toolCalls ?? []), ...(later.toolCalls ?? [])].map(call => [call.id, call])).values() + new Map(segments.flatMap(segment => segment.toolCalls ?? []).map(call => [call.id, call])).values() ); return { ...later, - content: [earlier.content, later.content].filter(Boolean).join('\n\n'), - ...(toolCalls.length ? { toolCalls } : {}) + content: segments + .map(segment => segment.content) + .filter(Boolean) + .join('\n\n'), + ...(toolCalls.length ? { toolCalls } : {}), + segments }; }; 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 dcf432e8446..e7cab6800b9 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts @@ -14,5 +14,4 @@ export * from './angular-object'; export * from './assistant-ui'; export * from './interfaces/public-api'; export * from './message'; - export * from './paragraph-state'; From d73c444c419e67be09c36376d53d9d3a2a9a4803 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 00:20:53 +0900 Subject: [PATCH 06/10] Derive assistant answer views from ordered segments --- .../assistant/model/assistantContract.ts | 3 - .../assistant/model/messageHistory.spec.ts | 58 ++++++++----------- .../assistant/model/messageHistory.ts | 21 ++++--- 3 files changed, 36 insertions(+), 46 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 587a95cdd4f..af5991d4582 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 @@ -30,9 +30,6 @@ export interface AssistantAnswerMessage { /** The last assistant round's id, used as the bubble key. */ id: string; role: 'assistant'; - /** Current UI projections; render segments to show text and actions in their original order. */ - content: string; - toolCalls?: AssistantToolCallRef[]; /** Ordered assistant rounds as stored by the server, including tool-only rounds. */ segments: AssistantMessageSegment[]; } diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts index 155b3417c31..d378f5003ac 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts @@ -12,8 +12,8 @@ import { describe, expect, it } from 'vitest'; -import type { AssistantMessage } from './assistantContract'; -import { joinPages } from './messageHistory'; +import type { AssistantAnswerMessage, AssistantMessage } from './assistantContract'; +import { answerText, answerToolCalls, joinPages } from './messageHistory'; describe('joinPages', () => { it('joins a reply split across pages under the later id and retains both action logs', () => { @@ -21,41 +21,36 @@ describe('joinPages', () => { const second = { id: 'a2', content: 'Done.', toolCalls: [{ id: 'c2', name: 'summarize' }] }; const earlier: AssistantMessage[] = [ { id: 'u1', role: 'user', content: 'Read this notebook' }, - { id: 'a1', role: 'assistant', content: first.content, toolCalls: first.toolCalls, segments: [first] } - ]; - const later: AssistantMessage[] = [ - { id: 'a2', role: 'assistant', content: second.content, toolCalls: second.toolCalls, segments: [second] } + { id: 'a1', role: 'assistant', segments: [first] } ]; + const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: [second] }]; expect(joinPages(earlier, later)).toEqual([ earlier[0], { id: 'a2', role: 'assistant', - content: 'Looking.\n\nDone.', - toolCalls: [ - { id: 'c1', name: 'read' }, - { id: 'c2', name: 'summarize' } - ], segments: [first, second] } ]); - expect(earlier[1].content).toBe('Looking.'); - expect(later[0].content).toBe('Done.'); + expect(answerText(earlier[1] as AssistantAnswerMessage)).toBe('Looking.'); + expect(answerText(later[0] as AssistantAnswerMessage)).toBe('Done.'); + expect(answerText(joinPages(earlier, later)[1] as AssistantAnswerMessage)).toBe('Looking.\n\nDone.'); + expect(answerToolCalls(joinPages(earlier, later)[1] as AssistantAnswerMessage)).toEqual([ + { id: 'c1', name: 'read' }, + { id: 'c2', name: 'summarize' } + ]); }); it('retains a tool-only reply across an empty intermediate page and deduplicates repeated calls', () => { const call = { id: 'c1', name: 'read' }; const first = { id: 'a1', content: '', toolCalls: [call] }; const second = { id: 'a2', content: 'Done.', toolCalls: [call] }; - const earlier: AssistantMessage[] = [ - { id: 'a1', role: 'assistant', content: '', toolCalls: [call], segments: [first] } - ]; - const later: AssistantMessage[] = [ - { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call], segments: [second] } - ]; + const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', segments: [first] }]; + const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: [second] }]; expect(joinPages(joinPages(earlier, []), later)).toEqual([ - { id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call], segments: [first, second] } + { id: 'a2', role: 'assistant', segments: [first, second] } ]); + expect(answerToolCalls(joinPages(joinPages(earlier, []), later)[0] as AssistantAnswerMessage)).toEqual([call]); }); it('keeps interleaved text and tool rounds in server order across an overlapping page boundary', () => { @@ -64,30 +59,27 @@ describe('joinPages', () => { { id: 'a2', content: 'Second thought.', toolCalls: [{ id: 'c2', name: 'summarize' }] }, { id: 'a3', content: 'Final answer.' } ]; - const earlier: AssistantMessage[] = [ - { id: 'a2', role: 'assistant', content: 'First thought.\n\nSecond thought.', segments: rounds.slice(0, 2) } - ]; - const later: AssistantMessage[] = [ - { id: 'a3', role: 'assistant', content: 'Second thought.\n\nFinal answer.', segments: rounds.slice(1) } - ]; + const earlier: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: rounds.slice(0, 2) }]; + const later: AssistantMessage[] = [{ id: 'a3', role: 'assistant', segments: rounds.slice(1) }]; expect(joinPages(earlier, later)).toEqual([ { id: 'a3', role: 'assistant', - content: 'First thought.\n\nSecond thought.\n\nFinal answer.', - toolCalls: [rounds[0].toolCalls![0], rounds[1].toolCalls![0]], segments: rounds } ]); + const answer = joinPages(earlier, later)[0] as AssistantAnswerMessage; + expect(answerText(answer)).toBe('First thought.\n\nSecond thought.\n\nFinal answer.'); + expect(answerToolCalls(answer)).toEqual([rounds[0].toolCalls![0], rounds[1].toolCalls![0]]); }); it('keeps separate turns apart and handles empty history', () => { const earlier: AssistantMessage[] = [ - { id: 'a1', role: 'assistant', content: 'First answer', segments: [{ id: 'a1', content: 'First answer' }] } + { id: 'a1', role: 'assistant', segments: [{ id: 'a1', content: 'First answer' }] } ]; const later: AssistantMessage[] = [ { id: 'u2', role: 'user', content: 'Another question' }, - { id: 'a2', role: 'assistant', content: 'Another answer', segments: [{ id: 'a2', content: 'Another answer' }] } + { id: 'a2', role: 'assistant', segments: [{ id: 'a2', content: 'Another answer' }] } ]; expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); expect(joinPages([], later)).toEqual(later); @@ -95,11 +87,9 @@ describe('joinPages', () => { expect(joinPages([], [])).toEqual([]); }); - it('does not merge an answer with the next question across pages', () => { + it('keeps a question apart from its answer on the next page', () => { const earlier: AssistantMessage[] = [{ id: 'u1', role: 'user', content: 'Question' }]; - const later: AssistantMessage[] = [ - { id: 'a1', role: 'assistant', content: 'Answer', segments: [{ id: 'a1', content: 'Answer' }] } - ]; + const later: AssistantMessage[] = [{ id: 'a1', role: 'assistant', segments: [{ id: 'a1', content: 'Answer' }] }]; expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); }); }); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts index 1e637cc4cd5..ea922720639 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts @@ -10,7 +10,18 @@ * limitations under the License. */ -import type { AssistantAnswerMessage, AssistantMessage } from './assistantContract'; +import type { AssistantAnswerMessage, AssistantMessage, AssistantToolCallRef } from './assistantContract'; + +export const answerText = (answer: AssistantAnswerMessage): string => + answer.segments + .map(segment => segment.content) + .filter(Boolean) + .join('\n\n'); + +export const answerToolCalls = (answer: AssistantAnswerMessage): AssistantToolCallRef[] => + Array.from( + new Map(answer.segments.flatMap(segment => segment.toolCalls ?? []).map(call => [call.id, call])).values() + ); export const mergeAnswers = ( earlier: AssistantAnswerMessage, @@ -19,16 +30,8 @@ export const mergeAnswers = ( const segments = Array.from( new Map([...earlier.segments, ...later.segments].map(segment => [segment.id, segment])).values() ); - const toolCalls = Array.from( - new Map(segments.flatMap(segment => segment.toolCalls ?? []).map(call => [call.id, call])).values() - ); return { ...later, - content: segments - .map(segment => segment.content) - .filter(Boolean) - .join('\n\n'), - ...(toolCalls.length ? { toolCalls } : {}), segments }; }; From 290cae6bc4fee1779c10ff928daf376d87b56d93 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 00:38:26 +0900 Subject: [PATCH 07/10] Align Assistant conversation and run contracts with server --- .../entities/assistant/model/assistantContract.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 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 af5991d4582..428883e3f52 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 @@ -14,11 +14,11 @@ export interface AssistantConversation { id: string; title?: string; /** Account that created the conversation, shown as "Started by …". */ - ownerId?: string; + ownerId: string; /** Whether the requesting user can send messages to the conversation, as the server decides. */ - canSendMessage?: boolean; + canSendMessage: boolean; /** Last activity (ISO time), shown in the conversation list. */ - updatedAt?: string; + updatedAt: string; } export type AssistantMessage = AssistantUserMessage | AssistantAnswerMessage; export interface AssistantUserMessage { @@ -45,11 +45,11 @@ export interface AssistantMessageSegment { /** One page of a conversation's history, oldest first. */ export interface AssistantMessagePage { messages: AssistantMessage[]; - /** Pass as `before` to load the page before this one; null when this page starts the conversation. */ - earlierCursor: string | null; + /** Pass as `before` to load older messages; null at the start of the conversation. */ + cursor: string | null; } export interface AssistantRunBody { - prompt: string; + content: string; } export type AssistantRunEvent = | { type: 'run.started'; runId?: string } @@ -67,10 +67,11 @@ export interface AssistantTransport { listConversations(): Promise; createConversation(body: { title?: string }): Promise; deleteConversation(conversationId: string): Promise; - /** The latest page of history, or the page before `before` (an `earlierCursor`). */ + /** 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 c1c0f5ad9587f00e9955f50396783b3824e895f7 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 00:46:48 +0900 Subject: [PATCH 08/10] Clarify Assistant history page ordering --- .../src/entities/assistant/model/assistantContract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 428883e3f52..e2792bdf48f 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 @@ -42,7 +42,7 @@ export interface AssistantMessageSegment { content: string; toolCalls?: AssistantToolCallRef[]; } -/** One page of a conversation's history, oldest first. */ +/** The transport reverses the server's newest-first page so messages are oldest first. */ export interface AssistantMessagePage { messages: AssistantMessage[]; /** Pass as `before` to load older messages; null at the start of the conversation. */ From e6c58f7612646125950d0c5b10344f805cb62cde Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 01:13:20 +0900 Subject: [PATCH 09/10] [ZEPPELIN-6741] Defer unused reveal result contract --- zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts index 837d748478a..913ebc62398 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts @@ -13,9 +13,6 @@ /** The notebook sidebar's width: its default and its resize range. The assistant panel shares it. */ export const NOTEBOOK_SIDEBAR_WIDTH = { initial: 370, min: 280, max: 800 } as const; -/** Result of a host reveal: scrolled to it, already visible, or not rendered. */ -export type AssistantRevealResult = 'shown' | 'visible' | 'missing'; - /** A notebook paragraph as the remote sees it: enough to label and link it. */ export interface AssistantParagraphRef { id: string; From 6ea532445041baa68a659e54bb2122eac574ba9f Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 01:23:59 +0900 Subject: [PATCH 10/10] Make Assistant history merge fields explicit --- .../src/entities/assistant/model/messageHistory.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts index ea922720639..aca53ce0bb0 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts @@ -31,7 +31,8 @@ export const mergeAnswers = ( new Map([...earlier.segments, ...later.segments].map(segment => [segment.id, segment])).values() ); return { - ...later, + id: later.id, + role: 'assistant', segments }; };