Repository navigation
[ZEPPELIN-6741] Define shared AI Assistant UI contracts and message history behavior #5561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aa2c462
87c86da
3726237
51fac8d
6e44f7b
d73c444
290cae6
c1c0f5a
e6c58f7
6ea5324
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }; | ||
|
|
||
| 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>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]); | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||
| 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 | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the careful review. |
||
| }; | ||
|
|
||
| /** | ||
| * 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]; | ||
| }; | ||
| 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'; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.failedprovides an error status rather than a message field.For example:
{ "conversationId": "...", "type": "run.failed", "payload": { "runId": "...", "error": { "status": 500 } } }We could add a
codefor more specific handling later if needed.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the clarification.
AssistantRunEventis the UI-facing event, not the raw WebSocket event. The follow-up transport in #5563 readspayload.error.statusand maps it to themessagefield; this is covered by its tests.