Repository navigation
[ZEPPELIN-6741] Define shared AI Assistant UI contracts and message history behavior - #5561
voidmatcha wants to merge 10 commits into
Conversation
|
This PR defines the types shared by the Assistant UI and its host, and adds the conversion from stored message history to visible UI messages. The current diff is +211/-0 across five files. Excluding 63 lines in This depends on #5558. Review and approval can happen now, but I will wait for #5558 to merge, then rebase this branch onto the latest |
4235999 to
95bd1b7
Compare
89a0192 to
80efed9
Compare
95bd1b7 to
397a5df
Compare
699239b to
3e95adf
Compare
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
3e95adf to
51fac8d
Compare
|
Rebased onto master after #5558 merged to remove duplicated prerequisite changes. This PR now contains only its five intended files. The relevant local checks passed. |
| { id: 'u2', role: 'user', content: 'Another question' }, | ||
| { id: 'a2', role: 'assistant', content: 'Another answer' } | ||
| ]; | ||
| expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); |
There was a problem hiding this comment.
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?
| expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); | |
| expect(joinPages(earlier, later)).toEqual([...earlier, ...later]); | |
| expect(joinPages([later[0]], [later[1]])).toEqual(later); |
| /** From history: the tools the server called before this answer. Live runs report them as events instead. */ | ||
| toolCalls?: AssistantToolCallRef[]; |
There was a problem hiding this comment.
When one answer spans several rounds (text, tool, text, tool), this shape merges them into a single content and a single toolCalls, so the order between text and tools is lost. The server history keeps that order, and the Direction in the frontend proposal (a renderer per tool inside the action log, the approval flow) may later need the answer shown step by step. Changing it then would change the shape of this contract, and the transport and UI along with it. How about keeping each round as segments instead of merging them, and joining them in the current UI? If merging is intended, could you share why?
f114b93 to
6e44f7b
Compare
|
Thanks for the review. I updated the history model to preserve Assistant rounds as ordered segments, matching how the server stores them. I also removed the |
| /** 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[]; |
There was a problem hiding this comment.
content and toolCalls can be derived from segments, so storing them as well lets them drift apart, as the input of the overlapping page test already does. How about dropping both fields, keeping only the rounds, and deriving them where needed?
export interface AssistantRound { id: string; content: string; toolCalls: AssistantToolCallRef[] }
export interface AssistantAnswerMessage { id: string; role: 'assistant'; rounds: AssistantRound[] }
export const answerText = (answer: AssistantAnswerMessage) =>
answer.rounds.map(round => round.content).filter(Boolean).join('\n\n');| 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; |
There was a problem hiding this comment.
Do these fields need to be optional?
| 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; |
There was a problem hiding this comment.
Should this be cursor?
| earlierCursor: string | null; | ||
| } | ||
| export interface AssistantRunBody { | ||
| prompt: string; |
There was a problem hiding this comment.
The server currently expects this field to be named content.
| 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; | ||
| openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable<AssistantRunEvent>; |
There was a problem hiding this comment.
It looks like cancellation isn't supported on the server side.
da1d64c to
290cae6
Compare
| content: string; | ||
| toolCalls?: AssistantToolCallRef[]; | ||
| } | ||
| /** One page of a conversation's history, oldest first. */ |
There was a problem hiding this comment.
The server’s listMessages() returns messages newest first, but this comment says “oldest first.” Should these match?
For example, assuming m5 is the newest message, the server returns [m5, m4, m3].
|
@seung-00 Thank you for the thorough review. I removed the duplicated fields and aligned the request and response contracts with the server. #5563 converts the server’s newest-first messages to chronological order for the UI. The ordering and client-only abort behavior are documented and covered by tests. |
| 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 }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 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'; |
There was a problem hiding this comment.
When would this type be used?
There was a problem hiding this comment.
This type is for the revealParagraph callback planned in ZEPPELIN-6749. The Angular host would report whether a linked paragraph was scrolled into view, was already visible, or could not be found, and the React panel would announce the missing case. Since it is not consumed in this PR, I removed the declaration here and will define the result type alongside that callback.
| return { | ||
| ...later, | ||
| segments | ||
| }; |
There was a problem hiding this comment.
Could we make the return value more explicit? Spreading later could ignore some of earlier’s fields, such as toolCalls.
There was a problem hiding this comment.
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.
What is this PR for?
Define the shared types and message history behavior used by the Assistant UI and its host. This gives subsequent UI work a common contract to build against.
Depends on #5558.
What type of PR is it?
Improvement
Todos
What is the Jira issue?
ZEPPELIN-6741
How should this be tested?
Run from
zeppelin-web-angular/:npm run build-project:sdk npm run typecheck:react npm --prefix projects/zeppelin-react test -- src/entities/assistant/model/messageHistory.spec.tsScreenshots (if appropriate)
N/A
Questions: