refactor: dedup setSessionId calls in session-switch path; add test coverage

Code review of 893f049 flagged 6 findings. This commit addresses them all:

#1 (real issue, production code): setSessionId was being called
TWICE per session switch - once explicitly from the cell-action
callback, and again from the prompt's _handleSwitchSession (which
also assigned _sessionId, called _resetStreamPointers, and set
_sessionSelect.value directly). The explicit setSessionId in the
cell-action callbacks (onCreateSession, onSwitchSession) is now
removed; the prompt's handlers do all of that bookkeeping through
a single setSessionId() call, which already implements the
dropdown + reset-stream-pointers logic.

#2 / #4 (test coverage): the existing switching test only checked
that the right HTTP call was made. New assertions: the prompt's
session-messages mock IS called again (history actually reloaded)
and the rendered history reflects the new session's content
(verifies the user sees the right messages, not stale DOM).

#3 (test coverage): new test for the error path. bind_existing
returns ok:false -> the prompt rolls the dropdown back to the
previous active id, leaves _sessionId unchanged, and shows a
Notification.error. This locks in the rollback behavior so a
future refactor can't silently break it.

#5 (test cleanup): mockedCallOpenCodeSessionMessages (the spy used
to verify history reload) is now declared at the top of the spec
file alongside the other mocked* variables, with a meaningful
name (mockedSessionMessages) instead of an inline jest.mocked()
that was hard to find.

#6 (source-doc): added a TODO(frontend) comment on
callOpenCodeSetActiveSession explaining why it is currently
unused (kept for a future 'switch between already-bound sessions'
flow that would avoid the bind round-trip) and warning a future
maintainer not to delete the corresponding server route without
removing the client function (or vice versa).

Tests: 77 jest (was 76), 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-28 17:00:29 +08:00
co-authored by Claude
parent 893f049eb7
commit 4ff5d7021c
4 changed files with 76 additions and 16 deletions
+58 -3
View File
@@ -128,7 +128,7 @@ jest.mock('../api/opencode_client', () => ({
callOpenCodeSetActiveSession: jest.fn().mockResolvedValue({
ok: true,
notebookPath: '',
activeSessionId: 'sess-B',
activeSessionId: '',
}),
callOpenCodeBindExistingSession: jest.fn().mockResolvedValue({
ok: true,
@@ -151,6 +151,7 @@ import {
callOpenCodeCreateNotebookSession,
callOpenCodeSetActiveSession,
callOpenCodeBindExistingSession,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents
} from '../api/opencode_client';
import { Notification } from '@jupyterlab/apputils';
@@ -174,6 +175,9 @@ const mockedSetActiveSession = callOpenCodeSetActiveSession as jest.MockedFuncti
const mockedBindExistingSession = callOpenCodeBindExistingSession as jest.MockedFunction<
typeof callOpenCodeBindExistingSession
>;
const mockedSessionMessages = callOpenCodeSessionMessages as jest.MockedFunction<
typeof callOpenCodeSessionMessages
>;
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
function makeFakeOutputs(items: any[]): any {
@@ -1115,7 +1119,7 @@ describe('OpenCodeInlinePrompt', () => {
expect((prompt as any)._sessionId).toBe('fresh-sid');
});
it('switching session calls onSwitchSession and reloads history', async () => {
it('switching session: bind_existing is called, SSE is closed, history is reloaded', async () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
@@ -1133,8 +1137,13 @@ describe('OpenCodeInlinePrompt', () => {
(actions.node.querySelector('button') as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
const prompt = (actions as any)._prompt;
// Mark sess-A as currently active.
// Mark sess-A as currently active (so we can verify the switch).
prompt.setSessionId('sess-A');
// Reset session-messages mock so we can assert it's called again.
mockedSessionMessages.mockClear();
mockedSessionMessages.mockResolvedValue({
messages: [{ role: 'user', content: 'from sess-B' }]
});
const select = prompt.node.querySelector(
'.opencode-session-select'
) as HTMLSelectElement;
@@ -1146,7 +1155,53 @@ describe('OpenCodeInlinePrompt', () => {
// because the session is not yet bound.
expect(mockedBindExistingSession).toHaveBeenCalled();
expect(mockedSetActiveSession).not.toHaveBeenCalled();
// Prompt's internal _sessionId is now the new one.
expect((prompt as any)._sessionId).toBe('sess-B');
// The prompt's history was reloaded for the new session.
expect(mockedSessionMessages).toHaveBeenCalledWith(
'foo.ipynb', expect.anything()
);
// The new history is rendered (the user sees sess-B's messages,
// not sess-A's stale DOM).
const userMsg = prompt.node.querySelector(
'.opencode-msg-user'
) as HTMLElement;
expect(userMsg.textContent).toBe('from sess-B');
});
it('switching session rolls back the dropdown + shows a Notification.error when bind fails', async () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
{ id: 'sess-A', title: 'first' },
{ id: 'sess-B', title: 'second' },
]
});
// bind returns ok:false -> the prompt's _handleSwitchSession
// must roll back the dropdown to the previous active id.
mockedBindExistingSession.mockResolvedValue({
ok: false, notebookPath: 'foo.ipynb', error: 'upstream down',
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
const prompt = (actions as any)._prompt;
prompt.setSessionId('sess-A');
const select = prompt.node.querySelector(
'.opencode-session-select'
) as HTMLSelectElement;
select.value = 'sess-B';
select.dispatchEvent(new Event('change'));
await new Promise(r => setTimeout(r, 5));
// Dropdown rolled back to the previous active id (sess-A).
expect(select.value).toBe('sess-A');
// Prompt's _sessionId is unchanged.
expect((prompt as any)._sessionId).toBe('sess-A');
// User sees a notification.
expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败');
});
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
+8 -1
View File
@@ -386,7 +386,14 @@ export async function callOpenCodeGetActiveSession(
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=... —
* switch which bound session is active. sessionId MUST already be
* in the bound list. */
* in the bound list.
*
* TODO(frontend): unused in v0.1.x — the cell-action's session
* switcher uses PUT /sessions/notebook (which does both bind and set
* active in one call). This is kept for a future "switch between
* already-bound sessions" flow that avoids the bind round-trip.
* Do NOT delete the corresponding server route without also
* removing this client function (or vice versa). */
export async function callOpenCodeSetActiveSession(
notebookPath: string,
sessionId: string,
+4 -3
View File
@@ -210,8 +210,8 @@ export class OpenCodeCellActions extends Widget {
if (!r.ok || !r.sessionId) {
throw new Error(r.error || 'create_session failed');
}
// Sync the prompt's view of "current session id".
this._prompt?.setSessionId(r.sessionId);
// The prompt's _handleCreateSession calls setSessionId
// internally; no need to set it here too.
return r.sessionId;
},
onSwitchSession: async (sessionId: string) => {
@@ -234,7 +234,8 @@ export class OpenCodeCellActions extends Widget {
if (!r.ok) {
return false;
}
this._prompt?.setSessionId(sessionId);
// The prompt's _handleSwitchSession calls setSessionId
// internally; no need to set it here too.
return true;
},
onReloadHistory: async () => {
+6 -9
View File
@@ -491,9 +491,9 @@ export class OpenCodeInlinePrompt extends Widget {
}
try {
const newId = await this._options.onCreateSession();
this._sessionId = newId;
// Drop any in-progress streaming DOM (the new session is empty).
this._resetStreamPointers();
// setSessionId() handles _sessionId + dropdown + reset pointers
// in one call; avoids duplicate work in this handler.
this.setSessionId(newId);
this._historyEl.textContent = '';
// Re-fetch the global list so the new id is in the dropdown
// and marked active.
@@ -529,8 +529,9 @@ export class OpenCodeInlinePrompt extends Widget {
try {
const ok = await this._options.onSwitchSession(sessionId);
if (ok) {
this._sessionId = sessionId;
this._resetStreamPointers();
// setSessionId() handles _sessionId + dropdown + reset
// pointers in one call; avoids duplicate work.
this.setSessionId(sessionId);
this._historyEl.textContent = '';
if (this._options.onReloadHistory) {
try {
@@ -539,10 +540,6 @@ export class OpenCodeInlinePrompt extends Widget {
console.warn('opencode_bridge: reload history failed', e);
}
}
// Re-mark the now-active option as selected.
if (this._sessionSelect) {
this._sessionSelect.value = sessionId;
}
} else {
// Roll back the select to the previous value.
if (this._sessionSelect) {