Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* 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.
*/

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 type AssistantMessage = AssistantUserMessage | AssistantAnswerMessage;
export interface AssistantUserMessage {
id: string;
role: 'user';
content: string;
}
export interface AssistantAnswerMessage {
/** The last assistant round's id, used as the bubble key. */
id: string;
role: 'assistant';
/** 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[];
}
/** 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. */
cursor: string | null;
}
export interface AssistantRunBody {
content: 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 }
| { type: 'tool_call.done'; toolCallId: string };
Comment on lines +54 to +61

@seung-00 seung-00 Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for reference, the server wraps the event data in payload. See AssistantEventPayload

Also, run.failed provides an error status rather than a message field.
For example:

{
  "conversationId": "...",
  "type": "run.failed",
  "payload": {
    "runId": "...",
    "error": {
      "status": 500
    }
  }
}

We could add a code for more specific handling later if needed.

@voidmatcha voidmatcha Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification. AssistantRunEvent is the UI-facing event, not the raw WebSocket event. The follow-up transport in #5563 reads payload.error.status and maps it to the message field; this is covered by its tests.


export type AssistantRunState = 'idle' | 'running' | 'disconnected';

export interface AssistantTransport {
/** Conversations of the note the transport was created for. */
listConversations(): Promise<AssistantConversation[]>;
createConversation(body: { title?: string }): Promise<AssistantConversation>;
deleteConversation(conversationId: string): Promise<void>;
/** The latest page of history, or the page before `before` (a page cursor). */
getMessages(conversationId: string, before?: string): Promise<AssistantMessagePage>;
/** Observe local run state, including a lost connection until history is reloaded. */
subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void;
/** Aborting stops local event consumption; it does not cancel a run already accepted by the server. */
openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable<AssistantRunEvent>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like cancellation isn't supported on the server side.

}

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';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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 { 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', () => {
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', segments: [first] }
];
const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: [second] }];
expect(joinPages(earlier, later)).toEqual([
earlier[0],
{
id: 'a2',
role: 'assistant',
segments: [first, second]
}
]);
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', segments: [first] }];
const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: [second] }];
expect(joinPages(joinPages(earlier, []), later)).toEqual([
{ 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', () => {
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', 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',
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', segments: [{ id: 'a1', content: 'First answer' }] }
];
const later: AssistantMessage[] = [
{ id: 'u2', role: 'user', content: 'Another question' },
{ id: 'a2', role: 'assistant', segments: [{ id: 'a2', content: 'Another answer' }] }
];
expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most common page boundary is a question at the end of the earlier page and its answer at the start of the later one, but no test covers it. All tests still pass after removing last?.role === 'assistant' && from joinPages, which would merge the question into the answer. How about adding this case?

Suggested change
expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);
expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);
expect(joinPages([later[0]], [later[1]])).toEqual(later);

expect(joinPages([], later)).toEqual(later);
expect(joinPages(earlier, [])).toEqual(earlier);
expect(joinPages([], [])).toEqual([]);
});

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', segments: [{ id: 'a1', content: 'Answer' }] }];
expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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 { 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,
later: AssistantAnswerMessage
): AssistantAnswerMessage => {
const segments = Array.from(
new Map([...earlier.segments, ...later.segments].map(segment => [segment.id, segment])).values()
);
return {
id: later.id,
role: 'assistant',
segments
};

@seung-00 seung-00 Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make the return value more explicit? Spreading later could ignore some of earlier’s fields, such as toolCalls.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful review. toolCalls live on each segment, so the existing merge retains the calls from both pages. I agree that the top-level merge policy should be explicit, so I replaced ...later with id, role, and segments. All five message-history tests pass.

};

/**
* 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];
};
26 changes: 26 additions & 0 deletions zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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;

/** A notebook paragraph as the remote sees it: enough to label and link it. */
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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

export * from './angular-object';
export * from './assistant-ui';
export * from './interfaces/public-api';
export * from './message';
export * from './paragraph-state';
Loading