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:
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user