From 9d29d8e32c77c9eaa068b82e83532b533f7f9213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 18 Apr 2026 17:01:53 +0800 Subject: [PATCH] Keep desktop replies stable across turns and fix OAuth callback routing The desktop chat store was dropping the previous assistant draft when a new user turn began because streaming text was cleared before it was flushed into message history. At the same time, the desktop OAuth flow was using an unregistered /api/haha-oauth/callback redirect URI, which caused provider authorization failures. This change flushes visible assistant drafts before starting a new turn, restores the OAuth redirect to the registered localhost callback, and adds a root callback handler while keeping the legacy API callback path compatible. Constraint: The OAuth provider only accepts the registered localhost /callback redirect URI Rejected: Keep the desktop-specific /api/haha-oauth/callback path | provider rejects unsupported redirect URIs Rejected: Rely on message_complete to persist visible assistant text | next user turn can begin before that event arrives Confidence: high Scope-risk: moderate Directive: Any UI-visible assistant draft must be flushed into messages before a new user turn resets streaming state Tested: bun test src/server/__tests__/haha-oauth-service.test.ts src/server/__tests__/haha-oauth-api.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test src/stores/chatStore.test.ts Tested: cd desktop && bun run lint Not-tested: Manual end-to-end desktop OAuth login after reinstall --- desktop/src/stores/chatStore.test.ts | 38 ++++++++++++++++ desktop/src/stores/chatStore.ts | 19 +++++++- src/server/__tests__/haha-oauth-api.test.ts | 2 +- .../__tests__/haha-oauth-service.test.ts | 2 +- src/server/api/haha-oauth.ts | 43 +++++++++++-------- src/server/index.ts | 5 +++ src/server/services/hahaOAuthService.ts | 15 ++----- 7 files changed, 90 insertions(+), 34 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index adb56acf..811e3d09 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -287,6 +287,44 @@ describe('chatStore history mapping', () => { }) }) + it('flushes the previous assistant draft before starting a new user turn', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'streaming', + connectionState: 'connected', + streamingText: '上一次分析结果 **还在流式区域**', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().sendMessage(TEST_SESSION_ID, '你是什么模型?') + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'assistant_text', + content: '上一次分析结果 **还在流式区域**', + }, + { + type: 'user_text', + content: '你是什么模型?', + }, + ]) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('') + }) + it('routes member-session messages through team mailbox delivery instead of websocket', async () => { const memberSessionId = 'team-member:security-reviewer@test-team' getMemberBySessionIdMock.mockReturnValue({ diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 0b971a66..c64f7f1a 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -180,8 +180,25 @@ export const useChatStore = create((set, get) => ({ set((s) => { const session = s.sessions[sessionId] ?? createDefaultSessionState() + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + const bufferedDelta = pendingDelta + pendingDelta = '' + const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim() - const newMessages = [...session.messages] + const newMessages = pendingAssistantText + ? [ + ...session.messages, + { + id: nextId(), + type: 'assistant_text' as const, + content: pendingAssistantText, + timestamp: Date.now(), + }, + ] + : [...session.messages] if (!isMemberSession && allTasksDone) { newMessages.push({ id: nextId(), diff --git a/src/server/__tests__/haha-oauth-api.test.ts b/src/server/__tests__/haha-oauth-api.test.ts index 422a4486..903ba2db 100644 --- a/src/server/__tests__/haha-oauth-api.test.ts +++ b/src/server/__tests__/haha-oauth-api.test.ts @@ -55,7 +55,7 @@ describe('POST /api/haha-oauth/start', () => { const data = (await res.json()) as { authorizeUrl: string; state: string } expect(data.authorizeUrl).toContain('code_challenge_method=S256') expect(data.authorizeUrl).toContain( - encodeURIComponent('http://localhost:54321/api/haha-oauth/callback'), + encodeURIComponent('http://localhost:54321/callback'), ) expect(data.state).toMatch(/^[A-Za-z0-9_-]+$/) }) diff --git a/src/server/__tests__/haha-oauth-service.test.ts b/src/server/__tests__/haha-oauth-service.test.ts index 659991ee..423ad24d 100644 --- a/src/server/__tests__/haha-oauth-service.test.ts +++ b/src/server/__tests__/haha-oauth-service.test.ts @@ -82,7 +82,7 @@ describe('HahaOAuthService — session management', () => { expect(session.authorizeUrl).toContain(`state=${encodeURIComponent(session.state)}`) expect(session.authorizeUrl).toContain('redirect_uri=') expect(session.authorizeUrl).toContain(encodeURIComponent( - 'http://localhost:54321/api/haha-oauth/callback', + 'http://localhost:54321/callback', )) }) diff --git a/src/server/api/haha-oauth.ts b/src/server/api/haha-oauth.ts index dbedb661..2803d2d9 100644 --- a/src/server/api/haha-oauth.ts +++ b/src/server/api/haha-oauth.ts @@ -2,7 +2,8 @@ * Haha OAuth REST API * * POST /api/haha-oauth/start — 生成 PKCE+state,返回 authorize URL - * GET /api/haha-oauth/callback — 用户浏览器 redirect 到此,完成 token 交换 + * GET /callback — 用户浏览器 redirect 到此,完成 token 交换 + * GET /api/haha-oauth/callback — 兼容旧路径 * GET /api/haha-oauth — 查询当前登录状态(不回传 token 本体) * GET /api/haha-oauth/status — 同上(legacy path) * DELETE /api/haha-oauth — 登出,删除 token 文件 @@ -52,24 +53,7 @@ export async function handleHahaOAuthApi( } if (action === 'callback' && req.method === 'GET') { - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - const error = url.searchParams.get('error') - - if (error) { - return html(renderCallbackPage(false, `OAuth provider returned: ${error}`)) - } - if (!code || !state) { - return html(renderCallbackPage(false, 'Missing code or state parameter')) - } - - try { - await hahaOAuthService.completeSession(code, state) - return html(renderCallbackPage(true, null)) - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return html(renderCallbackPage(false, msg)) - } + return handleHahaOAuthCallback(url) } if ((action === undefined || action === 'status') && req.method === 'GET') { @@ -96,6 +80,27 @@ export async function handleHahaOAuthApi( } } +export async function handleHahaOAuthCallback(url: URL): Promise { + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + + if (error) { + return html(renderCallbackPage(false, `OAuth provider returned: ${error}`)) + } + if (!code || !state) { + return html(renderCallbackPage(false, 'Missing code or state parameter')) + } + + try { + await hahaOAuthService.completeSession(code, state) + return html(renderCallbackPage(true, null)) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return html(renderCallbackPage(false, msg)) + } +} + function renderCallbackPage(success: boolean, errorMsg: string | null): string { if (success) { return ` diff --git a/src/server/index.ts b/src/server/index.ts index 03d3bd3b..a81120de 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -13,6 +13,7 @@ import { teamWatcher } from './services/teamWatcher.js' import { cronScheduler } from './services/cronScheduler.js' import { handleProxyRequest } from './proxy/handler.js' import { ProviderService } from './services/providerService.js' +import { handleHahaOAuthCallback } from './api/haha-oauth.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -128,6 +129,10 @@ export function startServer(port = PORT, host = HOST) { return new Response('WebSocket upgrade failed', { status: 400 }) } + if (url.pathname === '/callback') { + return handleHahaOAuthCallback(url) + } + // REST API if (url.pathname.startsWith('/api/')) { // Enforce authentication when required diff --git a/src/server/services/hahaOAuthService.ts b/src/server/services/hahaOAuthService.ts index 9fb77478..4cbd4f3a 100644 --- a/src/server/services/hahaOAuthService.ts +++ b/src/server/services/hahaOAuthService.ts @@ -53,6 +53,7 @@ type FetchProfileFn = ( ) => Promise<{ subscriptionType: SubscriptionType | null }> const SESSION_TTL_MS = 5 * 60 * 1000 +const OAUTH_CALLBACK_PATH = '/callback' export class HahaOAuthService { private sessions = new Map() @@ -108,14 +109,13 @@ export class HahaOAuthService { const codeChallenge = generateCodeChallenge(codeVerifier) const state = generateState() - const baseUrl = buildAuthUrl({ + const authorizeUrl = buildAuthUrl({ codeChallenge, state, port: serverPort, isManual: false, loginWithClaudeAi: true, }) - const authorizeUrl = this.rewriteCallbackPath(baseUrl, serverPort) const session: OAuthSession = { state, @@ -128,15 +128,6 @@ export class HahaOAuthService { return session } - private rewriteCallbackPath(url: string, port: number): string { - const u = new URL(url) - u.searchParams.set( - 'redirect_uri', - `http://localhost:${port}/api/haha-oauth/callback`, - ) - return u.toString() - } - getSession(state: string): OAuthSession | null { const s = this.sessions.get(state) if (!s) return null @@ -197,7 +188,7 @@ export class HahaOAuthService { const requestBody = { grant_type: 'authorization_code', code, - redirect_uri: `http://localhost:${port}/api/haha-oauth/callback`, + redirect_uri: `http://localhost:${port}${OAUTH_CALLBACK_PATH}`, client_id: getOauthConfig().CLIENT_ID, code_verifier: verifier, state,