diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 61f3216..597d3ce 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -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; 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', () => { diff --git a/src/api/opencode_client.ts b/src/api/opencode_client.ts index 6bf85dc..78b766f 100644 --- a/src/api/opencode_client.ts +++ b/src/api/opencode_client.ts @@ -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, diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts index ed8f272..321fddf 100644 --- a/src/components/opencode_cell_actions.ts +++ b/src/components/opencode_cell_actions.ts @@ -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 () => { diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index 9d4d984..250a503 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -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) {