Skip to content

[ZEPPELIN-6742] Connect the AI Assistant frontend to conversation REST and WebSocket APIs - #5563

Merged
tbonelee merged 16 commits into
apache:masterfrom
voidmatcha:voidmatcha/assistant-v2-6-transport
Oct 18, 2026
Merged

tbonelee merged 16 commits into
apache:masterfrom
voidmatcha:voidmatcha/assistant-v2-6-transport

Conversation

@voidmatcha

@voidmatcha voidmatcha commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

What is this PR for?

Connect the Assistant frontend to the conversation REST and WebSocket APIs. The transport provides conversation creation and retrieval, message sending, and response delivery for the UI.

Server PR #5547, #5558, and UI contracts #5561 have merged. This PR is now based on master.

What type of PR is it?

Feature

Todos

What is the Jira issue?

ZEPPELIN-6742

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/assistantTransport.spec.ts

Also verify the REST and WebSocket integration against the final API contract from server PR #5547.

Screenshots (if appropriate)

N/A

Questions:

  • Does the license files need to update? No
  • Is there breaking changes for older versions? No
  • Does this needs documentation? No

@voidmatcha
voidmatcha changed the base branch from temp/seung-00/assistant-transport-base to branch-seung-00-assistant-transport-base-temp October 9, 2026 01:35
@voidmatcha voidmatcha closed this Oct 9, 2026
@voidmatcha voidmatcha reopened this Oct 9, 2026
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch 2 times, most recently from 2aaa236 to b75de05 Compare October 9, 2026 09:09
@voidmatcha
voidmatcha changed the base branch from branch-seung-00-assistant-transport-base-temp to branch-seung-00-assistant-transport-clean-temp October 9, 2026 09:09
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from b75de05 to c470802 Compare October 9, 2026 09:13
@voidmatcha

voidmatcha commented Oct 9, 2026 •

Copy link
Copy Markdown
Member Author

This PR connects the frontend to the conversation REST API and WebSocket run events.

The current diff is +1,271/-8 across five files. Excluding 795 lines in *.spec.* tests, the review diff is +476/-8. There is no lockfile change.

Server PR #5547, #5558, and #5561 have merged, and this PR targets master. The latest history rewrite preserves the previous file tree. I will confirm CI on the new head before merging.

@voidmatcha
voidmatcha force-pushed the branch-seung-00-assistant-transport-clean-temp branch from b772a8d to 9f119fc Compare October 10, 2026 00:50
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from 0c3b303 to c41de73 Compare October 10, 2026 00:50
@voidmatcha
voidmatcha force-pushed the branch-seung-00-assistant-transport-clean-temp branch from 9f119fc to b0b71a0 Compare October 10, 2026 08:27
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from c41de73 to 3af8991 Compare October 10, 2026 08:27
@voidmatcha
voidmatcha force-pushed the branch-seung-00-assistant-transport-clean-temp branch from b0b71a0 to fc57755 Compare October 10, 2026 08:37
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from 3af8991 to 3bcb8e2 Compare October 10, 2026 08:37
@voidmatcha
voidmatcha force-pushed the branch-seung-00-assistant-transport-clean-temp branch from fc57755 to 404c586 Compare October 10, 2026 13:40
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from 3bcb8e2 to e3e2119 Compare October 10, 2026 13:40
@voidmatcha
voidmatcha force-pushed the branch-seung-00-assistant-transport-clean-temp branch from 404c586 to e345f98 Compare October 10, 2026 15:44
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from e3e2119 to c221c82 Compare October 10, 2026 15:45
@voidmatcha voidmatcha changed the title [ZEPPELIN-6742] Connect the Assistant frontend to conversation REST and WebSocket APIs [ZEPPELIN-6742] Connect the AI Assistant frontend to conversation REST and WebSocket APIs Oct 10, 2026
@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from c221c82 to e4880f9 Compare October 11, 2026 04:28

@miinhho miinhho left a comment •

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.

Transport-level review. I reproduced the reported sequences by loading the module and controlling HTTP responses and socket events. I have not verified them against a live server or the notebook UI. The authentication comment is a contract question, not a confirmed integration defect.

// 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);

@miinhho miinhho Oct 11, 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.

This removes the previous disconnected marker before the new request has been accepted.

I reproduced this sequence: run A starts, its connection closes, and a retry is sent without reloading history. When that retry receives run.failed with status 409, the transport reports idle, even though A's history has not been recovered. The failure message itself says the conversation is still answering.

This conflicts with the documented behavior of retaining the lost-connection state until history is reloaded. Please preserve that uncertainty when the retry is rejected, and cover the disconnect → retry → 409 sequence in a regression test.

@voidmatcha voidmatcha Oct 11, 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 catching this. I addressed the disconnect -> retry -> 409 sequence in 1161409d6 under the earlier run-state design. The later simplification in 42f456587 removed disconnected tracking and that regression test, so this state transition no longer exists in the current PR.

{},
onAuthError
);
if (!before && runs.disconnected.delete(conversationId)) notifyRunState(runs, conversationId);

@miinhho miinhho Oct 11, 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.

A successful latest-page response clears the current disconnected marker without checking whether the request predates that disconnect.

I reproduced this with an accepted run: start getMessages(), delay its response, close the socket, then release the history response. The state changes from disconnected to idle, although no history request was started after the connection loss.

Please ensure that a request spanning a newer disconnect cannot clear that disconnect's recovery state, and add a regression test for this ordering.

@voidmatcha voidmatcha Oct 11, 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 catching the race. I addressed the history-response ordering in 5740ccdc2 under the earlier run-state design. The later simplification in 42f456587 removed disconnected tracking and that regression test, so this state transition no longer exists in the current PR.

...later,
content: [earlier.content, later.content].filter(Boolean).join('\n\n'),
...(toolCalls.length ? { toolCalls } : {})
segments

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.

These parts seem to conflict with the changes in #5561

@voidmatcha voidmatcha Oct 16, 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.

Right, these overlapped with the segments changes in #5561. Now that #5561 has merged, I rebased onto master and retargeted, so those files match master and only the auth error change remains in d098067e5.

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;

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 server doesn’t provide a redirect location. I’d prefer to leave unimplemented features out of the contract. What do you think?

@voidmatcha voidmatcha Oct 16, 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.

You're right. Nothing on the server sets Location on a 401: form auth redirects to /api/login with a 302 that fetch follows to a 405, and Kerberos returns a 401 with only WWW-Authenticate. I narrowed the handler to (status: number) => void and dropped location from the error in d098067e5.

disconnected: Map<string, object>;
listeners: Map<string, Set<(state: AssistantRunState) => void>>;
stopTracking?: () => void;
}

@seung-00 seung-00 Oct 16, 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.

I wonder if we could simplify the per-conversation state tracking a bit. One concern is that a client-side timeout leaves pending and active intact. If no terminal event arrives and the WebSocket stays connected, the conversation could remain blocked.

Some of this complexity seems to come from sharing the notebook WebSocket, where events can keep arriving after the user leaves the view. A separate assistant WebSocket tied to the component lifecycle might simplify this eventually.

One approach we could consider for this PR, without changing the server, is:

  1. Keep a single slot for the current request:
    for example: : { conversationId, phase: 'pending' | 'running', runId? }
    The server already rejects concurrent requests in the same conversation with 409. We could ignore events whose conversationId does not match the slot, and clear both the slot and its subscription when the component unmounts.

  2. Track the remaining edge case separately:
    A user sends hi in conversation C1, leaves the page, then returns to C1 and sends bye. An event from hi could arrive while the client is waiting for bye and be mistaken for its response.
    The single-slot approach would not fully handle this timing-dependent case, but it seems unlikely to happen often. Could we open a separate issue for it and address it later?

@voidmatcha voidmatcha Oct 16, 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, done in 42f456587. Each run now keeps only its own { conversationId, phase, runId? }, drops events for other conversations, and releases its subscriptions when it ends. Duplicate sends are left to the server's 409, and the abandoned/disconnected tracking is gone. After rebasing onto master, I also dropped the now unused AssistantRunState and subscribeRunState from the contract in a5127fe31.

Could you open an issue for 2? Retrying after a client timeout and losing events after a reconnect share the same cause, so it would be good to include them too.

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.

One additional case for the follow-up issue: if two openRun() calls for the same conversation are pending at the same time, both can accept the first run.started and bind to the same runId. The second request’s 409 run.failed may then be ignored. This seems to be another consequence of not being able to correlate a client request with its initial server response. Could we include this scenario as well?

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.

@voidmatcha
voidmatcha changed the base branch from master to branch-voidmatcha-assistant-storybook-temp October 16, 2026 23:39
@voidmatcha
voidmatcha changed the base branch from branch-voidmatcha-assistant-storybook-temp to master October 16, 2026 23:39
return active && !sessionSignal?.aborted;
},
transport,
setActive(next: boolean) {

@tbonelee tbonelee Oct 17, 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 you clarify which UX scenario requires the same scopeTransport to be deactivated and later reactivated? Managing a reactivatable active flag alongside the permanent sessionSignal and per-run signals seems to add lifecycle complexity.

It also leaves an edge case where a REST request started before setActive(false) can finish after setActive(true) and still pass guarded(). If this is for panel visibility, that could perhaps be managed separately. If it is for revisiting a note, we could cache its UI state and recreate the lightweight transport instead.

If reactivation is not required, would a one-shot per-note scope with an AbortSignal for both REST and WebSocket work be simpler?

// 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 } : {};

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.

Is this normalization and 80-character limit intended to be a UI presentation rule or a conversation invariant? The server does not appear to enforce the same policy, so stored titles can differ depending on the client. If it is an invariant, could it be enforced server-side as well? If it is only presentational, truncating before persistence may discard part of the original title.

@voidmatcha

voidmatcha commented Oct 17, 2026 •

Copy link
Copy Markdown
Member Author

@tbonelee Thanks for the careful review. I made the per-note transport scope one-shot in 23880cd1a and removed client-side title normalization and truncation in 34034fc04.

I agree that concurrent openRun() calls are another case of the request-to-run correlation problem. @seung-00, could you include that case in the follow-up issue you proposed?

@voidmatcha
voidmatcha force-pushed the voidmatcha/assistant-v2-6-transport branch from 9df7e4d to 34034fc Compare October 17, 2026 14:32
Comment on lines +65 to +69
listConversations(signal?: AbortSignal): Promise<AssistantConversation[]>;
createConversation(body: { title?: string }, signal?: AbortSignal): Promise<AssistantConversation>;
deleteConversation(conversationId: string, signal?: AbortSignal): 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;
getMessages(conversationId: string, before?: string, signal?: AbortSignal): Promise<AssistantMessagePage>;

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.

Is there a specific reason these REST calls need AbortSignal, unlike our other API calls? I understand its role in stopping the WebSocket event loop, but I’m not sure we need it for these regular REST requests as well.

subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void;
getMessages(conversationId: string, before?: string, signal?: AbortSignal): Promise<AssistantMessagePage>;
/** 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.

Would it be okay to omit the GET /{conversationId} API?

Comment on lines +308 to +316
const toConversation = (conversation: ConversationSummary): AssistantConversation => {
return {
id: conversation.id,
title: conversation.title,
ownerId: conversation.ownerId,
canSendMessage: conversation.canSendMessage,
updatedAt: conversation.updatedAt
};
};

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 simplify this to:
const toConversation = (conversation: ConversationSummary): AssistantConversation => ({ ...conversation });

Also, are these types intended to separate the server response from the UI model? If so, could we name this function more clearly to reflect that?

@seung-00

seung-00 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

@voidmatcha
I might be missing something, but would it be simpler to separate the conversation REST APIs from WebSocket streaming(openRun)? The REST calls seem similar to our other APIs, but coupling them with WebSocket streaming adds complexity.

For example, I’m not sure the REST calls need the same lifecycle handling in scopeTransport as openRun. Also, createAssistantTransport requires a socket even when the caller only needs the REST APIs.

@voidmatcha

Copy link
Copy Markdown
Member Author

@seung-00 Thank you for the thoughtful review. I separated the conversation REST API from WebSocket runs. createAssistantConversationApi() handles listing, creating, and deleting conversations and fetching messages without a socket. socketRun() sends the message and consumes run events. I also removed scopeTransport, which managed both paths together.

I removed AbortSignal from the REST contract along with that shared lifecycle handling. The REST client therefore no longer suppresses late responses or 401/405 auth callbacks. When the panel is connected, its caller will need to check that responses and auth callbacks still belong to the current note and session. socketRun() retains its AbortSignal so the caller can stop consuming events, for example after navigating to another note. It does not cancel a run already accepted by the server.

The current frontend flow needs GET {apiBase}/notes/{noteId}/conversations and GET {apiBase}/notes/{noteId}/conversations/{conversationId}/messages?limit=50, with cursor for older messages. It does not use the single-conversation GET endpoint, so I left that endpoint out of the frontend contract.

I renamed the server response type to ConversationMetadataResponse and the conversion function to toAssistantConversation to make the boundary with the UI model explicit. updatedAt is used to order the list and remains in the UI model. It currently has no noteId or createdAt, so the conversion copies only its declared fields instead of spreading the entire server response.

voidmatcha added a commit that referenced this pull request Oct 18, 2026
Align the temporary integration branch with PR #5563's split conversation API and run stream. Preserve the assembled Assistant UI from the existing integration branch.

Confidence: high
Scope-risk: fixture panel work based on the old transport needs the new contract
Not-tested: browser end-to-end Assistant flow
@tbonelee
tbonelee merged commit d01a1c3 into apache:master Oct 18, 2026
41 of 42 checks passed
@tbonelee

Copy link
Copy Markdown
Contributor

Merged into master (d01a1c3).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants