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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-18 17:01:53 +08:00
parent d83efc1b04
commit 9d29d8e32c
7 changed files with 90 additions and 34 deletions

View File

@ -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({

View File

@ -180,8 +180,25 @@ export const useChatStore = create<ChatStore>((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(),

View File

@ -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_-]+$/)
})

View File

@ -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',
))
})

View File

@ -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<Response> {
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 `<!doctype html>

View File

@ -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

View File

@ -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<string, OAuthSession>()
@ -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,