mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
d83efc1b04
commit
9d29d8e32c
@ -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({
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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_-]+$/)
|
||||
})
|
||||
|
||||
@ -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',
|
||||
))
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user