mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: resolve desktop startup and provider regressions
Fixes #827, #857, #860, #885. Reproduced #827 with a delayed pasted-image FileReader result that reappeared after send; fixed by invalidating stale paste callbacks after submit or session switch. Reproduced #857 with TCP accepting while /health stayed unhealthy; fixed Electron sidecar readiness to require HTTP /health status ok before continuing. Reproduced #860 through OpenAI-compatible sampling param pass-through and missing GLM-5.2 context metadata; fixed sampling params to be opt-in and added GLM-5.2 context windows for preset and core resolution. Reproduced #885 with a truncated long sidebar title lacking a row tooltip; fixed by exposing the full title on the session row. Tested: cd desktop && bun run test -- src/components/chat/ChatInput.test.tsx src/components/layout/Sidebar.test.tsx --run Tested: cd desktop && bun test electron/services/sidecarManager.test.ts Tested: bun test src/server/__tests__/proxy-transform.test.ts src/server/__tests__/provider-presets.test.ts src/services/compact/autoCompact.test.ts Tested: bun run check:server Tested: bun run check:desktop Tested: bun run check:native Confidence: high Scope-risk: moderate
This commit is contained in:
parent
1f7b1e68c4
commit
9aaf3c9e5f
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import net from 'node:net'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@ -20,6 +21,7 @@ import {
|
||||
reserveServerPort,
|
||||
resolveHostTriple,
|
||||
spawnSidecar,
|
||||
waitForServer,
|
||||
windowsPowerShellOverride,
|
||||
writeLastServerPort,
|
||||
type SidecarChild,
|
||||
@ -29,6 +31,24 @@ function fakeChild(pid = 4321) {
|
||||
return { pid, kill: vi.fn() } as unknown as SidecarChild & { kill: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function listen(server: http.Server, host = '127.0.0.1'): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, host, () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Could not resolve HTTP test port'))
|
||||
return
|
||||
}
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function close(server: http.Server): Promise<void> {
|
||||
return new Promise(resolve => server.close(() => resolve()))
|
||||
}
|
||||
|
||||
describe('Electron sidecar manager', () => {
|
||||
it('maps host platform to existing sidecar target triples', () => {
|
||||
expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin')
|
||||
@ -262,4 +282,20 @@ describe('Electron sidecar manager', () => {
|
||||
// Invalid entries are skipped without throwing.
|
||||
await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not treat a raw TCP accept as server readiness without healthy /health', async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(503, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ status: 'starting' }))
|
||||
})
|
||||
const port = await listen(server)
|
||||
|
||||
try {
|
||||
await expect(waitForServer('127.0.0.1', port, 300)).rejects.toThrow(
|
||||
/desktop server did not report healthy at http:\/\/127\.0\.0\.1:\d+\/health/,
|
||||
)
|
||||
} finally {
|
||||
await close(server)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -164,26 +164,45 @@ export function preferredServerPorts(env: NodeJS.ProcessEnv = process.env): numb
|
||||
|
||||
export async function waitForServer(host: string, port: number, timeoutMs = 10_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const healthUrl = `http://${host}:${port}/health`
|
||||
let lastError: Error | null = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await canConnect(host, port)) return
|
||||
try {
|
||||
await assertServerHealth(healthUrl, Math.min(500, Math.max(100, deadline - Date.now())))
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
await sleep(150)
|
||||
}
|
||||
throw new Error(`desktop server did not start listening on ${host}:${port} within ${Math.round(timeoutMs / 1000)} seconds`)
|
||||
|
||||
const reason = lastError ? `: ${lastError.message}` : ''
|
||||
throw new Error(`desktop server did not report healthy at ${healthUrl} within ${Math.round(timeoutMs / 1000)} seconds${reason}`)
|
||||
}
|
||||
|
||||
function canConnect(host: string, port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const socket = net.connect({ host, port, timeout: 200 })
|
||||
socket.once('connect', () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
async function assertServerHealth(healthUrl: string, timeoutMs: number): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
socket.once('error', () => resolve(false))
|
||||
})
|
||||
if (!response.ok) throw new Error(`healthcheck returned ${response.status}`)
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new Error(`healthcheck returned non-JSON response from ${healthUrl}`)
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object' || !('status' in body) || body.status !== 'ok') {
|
||||
throw new Error(`healthcheck returned invalid response from ${healthUrl}`)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import { act } from 'react'
|
||||
|
||||
@ -208,6 +208,10 @@ describe('ChatInput file mentions', () => {
|
||||
mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps unsent composer drafts isolated when switching between session tabs', async () => {
|
||||
const historySessionId = 'history-session'
|
||||
useTabStore.setState({
|
||||
@ -1158,6 +1162,57 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores pasted images that finish loading after the prompt was sent', async () => {
|
||||
class DeferredFileReader {
|
||||
result: string | ArrayBuffer | null = null
|
||||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
|
||||
|
||||
readAsDataURL(file: Blob) {
|
||||
pendingReaders.push({ reader: this, file })
|
||||
}
|
||||
}
|
||||
const pendingReaders: Array<{ reader: DeferredFileReader; file: Blob }> = []
|
||||
vi.stubGlobal('FileReader', DeferredFileReader)
|
||||
|
||||
render(<ChatInput compact />)
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
const file = new File(['image'], 'late.png', { type: 'image/png' })
|
||||
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
items: [{
|
||||
type: 'image/png',
|
||||
getAsFile: () => file,
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(pendingReaders).toHaveLength(1)
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: 'send now',
|
||||
selectionStart: 'send now'.length,
|
||||
},
|
||||
})
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: 'send now',
|
||||
attachments: [],
|
||||
})
|
||||
|
||||
act(() => {
|
||||
pendingReaders[0]!.reader.result = 'data:image/png;base64,LATE'
|
||||
pendingReaders[0]!.reader.onload?.({} as ProgressEvent<FileReader>)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByAltText(/pasted-image-/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps slash and @ popovers outside the drop target clipping context', async () => {
|
||||
mocks.search.mockResolvedValueOnce({
|
||||
currentPath: '/repo',
|
||||
|
||||
@ -116,6 +116,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const previousActiveTabIdRef = useRef<string | null>(null)
|
||||
const inputRef = useRef(input)
|
||||
const attachmentsRef = useRef(attachments)
|
||||
const pasteGenerationRef = useRef(0)
|
||||
const setComposerInput = useCallback((value: string) => {
|
||||
inputRef.current = value
|
||||
setInput(value)
|
||||
@ -176,6 +177,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
}
|
||||
chatStore.setComposerDraft(sessionId, draft)
|
||||
}, [])
|
||||
const invalidatePendingPastes = useCallback(() => {
|
||||
pasteGenerationRef.current += 1
|
||||
}, [])
|
||||
|
||||
const isMemberSession = !!memberInfo
|
||||
const isActive = chatState !== 'idle'
|
||||
@ -222,6 +226,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
}
|
||||
|
||||
const nextDraft = activeTabId ? useChatStore.getState().sessions[activeTabId]?.composerDraft : undefined
|
||||
invalidatePendingPastes()
|
||||
setComposerInput(nextDraft?.input ?? '')
|
||||
setComposerAttachments(nextDraft?.attachments ?? [])
|
||||
setPlusMenuOpen(false)
|
||||
@ -234,7 +239,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
setEditingQueuedMessageId(null)
|
||||
setEditingQueuedMessageText('')
|
||||
previousActiveTabIdRef.current = activeTabId
|
||||
}, [activeTabId, saveComposerDraft, setComposerAttachments, setComposerInput])
|
||||
}, [activeTabId, invalidatePendingPastes, saveComposerDraft, setComposerAttachments, setComposerInput])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -711,6 +716,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
displayAttachments: visibleAttachmentPayload,
|
||||
})
|
||||
}
|
||||
invalidatePendingPastes()
|
||||
setComposerInput('')
|
||||
setComposerAttachments([])
|
||||
useChatStore.getState().clearComposerDraft(activeTabId!)
|
||||
@ -817,8 +823,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
if (!file) continue
|
||||
|
||||
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const pasteGeneration = pasteGenerationRef.current
|
||||
const pastedSessionId = activeTabId
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
if (pasteGeneration !== pasteGenerationRef.current) return
|
||||
if (pastedSessionId !== useTabStore.getState().activeTabId) return
|
||||
setComposerAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
|
||||
@ -322,6 +322,19 @@ describe('Sidebar', () => {
|
||||
await waitFor(() => expect(fetchSessions).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('exposes the full session title as a row tooltip when the label is truncated', () => {
|
||||
const longTitle = '这是一个非常非常长的会话标题,用来验证侧边栏截断后仍然可以通过气泡查看完整内容'
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('session-long-title', longTitle, '/workspace/alpha', '2026-05-15T10:00:00.000Z'),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByRole('button', { name: new RegExp(longTitle) })).toHaveAttribute('title', longTitle)
|
||||
})
|
||||
|
||||
it('reorders project groups by dragging project headers while preserving expanded state', async () => {
|
||||
const base = new Date('2026-05-15T10:00:00.000Z').getTime()
|
||||
useSessionStore.setState({
|
||||
|
||||
@ -975,6 +975,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
}
|
||||
`}
|
||||
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
|
||||
title={session.title || 'Untitled'}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{isBatchMode ? (
|
||||
|
||||
@ -141,6 +141,7 @@ describe('provider presets API', () => {
|
||||
expect(zhipu?.apiKeyUrl).toBe('https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D')
|
||||
expect(zhipu?.promoText).toContain('cc-haha')
|
||||
expect(zhipu?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
|
||||
expect(zhipu?.modelContextWindows?.['glm-5.2']).toBe(200000)
|
||||
expect(zhipu?.modelContextWindows?.['glm-5.1']).toBe(200000)
|
||||
expect(zhipu?.modelContextWindows?.['glm-4.5-air']).toBe(128000)
|
||||
expect(kimi?.apiKeyUrl).toBe('https://platform.kimi.com/console/api-keys')
|
||||
|
||||
@ -63,6 +63,34 @@ describe('anthropicToOpenaiChat', () => {
|
||||
expect(result.stop).toEqual(['END', 'STOP'])
|
||||
})
|
||||
|
||||
test('omits Anthropic sampling params by default for OpenAI-compatible providers', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'glm-5.2',
|
||||
max_tokens: 100,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.temperature).toBeUndefined()
|
||||
expect(result.top_p).toBeUndefined()
|
||||
})
|
||||
|
||||
test('can explicitly pass sampling params for chat providers that accept them', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
|
||||
const result = anthropicToOpenaiChat(req, { passSamplingParams: true })
|
||||
expect(result.temperature).toBe(0.7)
|
||||
expect(result.top_p).toBe(0.9)
|
||||
})
|
||||
|
||||
test('tools conversion', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
@ -407,6 +435,34 @@ describe('anthropicToOpenaiResponses', () => {
|
||||
expect(result.input).toEqual([{ type: 'message', role: 'user', content: 'Hello' }])
|
||||
})
|
||||
|
||||
test('omits Anthropic sampling params by default for Responses-compatible providers', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'glm-5.2',
|
||||
max_tokens: 100,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.temperature).toBeUndefined()
|
||||
expect(result.top_p).toBeUndefined()
|
||||
})
|
||||
|
||||
test('can explicitly pass sampling params for Responses providers that accept them', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
|
||||
const result = anthropicToOpenaiResponses(req, { passSamplingParams: true })
|
||||
expect(result.temperature).toBe(0.7)
|
||||
expect(result.top_p).toBe(0.9)
|
||||
})
|
||||
|
||||
test('tools conversion uses top-level name', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
"CC_HAHA_SEND_DISABLED_THINKING": "1"
|
||||
},
|
||||
"modelContextWindows": {
|
||||
"glm-5.2": 200000,
|
||||
"glm-5.1": 200000,
|
||||
"glm-5-turbo": 200000,
|
||||
"glm-4.5-air": 128000
|
||||
|
||||
@ -21,6 +21,7 @@ type OpenAIChatImageContentMode = 'vision' | 'text_only'
|
||||
type OpenAIChatTransformOptions = {
|
||||
roundTripReasoningContent?: boolean
|
||||
passThinkingToggle?: boolean
|
||||
passSamplingParams?: boolean
|
||||
imageContentMode?: OpenAIChatImageContentMode
|
||||
}
|
||||
|
||||
@ -68,9 +69,12 @@ export function anthropicToOpenaiChat(
|
||||
// Claude Code sends very large values (e.g. 128K) that exceed many
|
||||
// providers' limits (DeepSeek: 8192, etc.).
|
||||
|
||||
// temperature & top_p
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p
|
||||
// Claude Code sends Anthropic sampling params that some compatible
|
||||
// providers reject. Keep them opt-in for providers known to accept them.
|
||||
if (options.passSamplingParams) {
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p
|
||||
}
|
||||
|
||||
// stop_sequences → stop
|
||||
if (body.stop_sequences && body.stop_sequences.length > 0) {
|
||||
|
||||
@ -17,6 +17,7 @@ import { stripLeadingBillingHeader } from './billingHeader.js'
|
||||
export type OpenAIResponsesTransformOptions = {
|
||||
/** Stable cache routing key, forwarded as `prompt_cache_key`. */
|
||||
cacheKey?: string
|
||||
passSamplingParams?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -59,9 +60,12 @@ export function anthropicToOpenaiResponses(
|
||||
// max_tokens — omit to let upstream provider use its own default/max.
|
||||
// Claude Code sends very large values that exceed many providers' limits.
|
||||
|
||||
// temperature & top_p
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p
|
||||
// Claude Code sends Anthropic sampling params that some compatible
|
||||
// providers reject. Keep them opt-in for providers known to accept them.
|
||||
if (options.passSamplingParams) {
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p
|
||||
}
|
||||
|
||||
// tools
|
||||
if (body.tools && body.tools.length > 0) {
|
||||
|
||||
@ -32,6 +32,7 @@ describe('model context window resolution', () => {
|
||||
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(1_000_000)
|
||||
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
|
||||
expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144)
|
||||
expect(getContextWindowForModel('zai-org/GLM-5.2')).toBe(200_000)
|
||||
expect(getContextWindowForModel('glm-5.1')).toBe(200_000)
|
||||
expect(getContextWindowForModel('glm-4.5-air')).toBe(128_000)
|
||||
})
|
||||
@ -61,6 +62,7 @@ describe('model context window resolution', () => {
|
||||
|
||||
test('derives auto-compact thresholds from provider context windows', () => {
|
||||
expect(getAutoCompactThreshold('deepseek-v4-pro')).toBe(967_000)
|
||||
expect(getAutoCompactThreshold('zai-org/GLM-5.2')).toBe(167_000)
|
||||
expect(getAutoCompactThreshold('glm-5.1')).toBe(167_000)
|
||||
expect(getAutoCompactThreshold('glm-4.5-air')).toBe(95_000)
|
||||
expect(getAutoCompactThreshold('kimi-k2.6')).toBe(229_144)
|
||||
|
||||
@ -19,6 +19,7 @@ const DIRECT_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
||||
'minimax-m3': 1_000_000,
|
||||
'minimax-m2.7': 204_800,
|
||||
'minimax-m2.7-highspeed': 204_800,
|
||||
'glm-5.2': 200_000,
|
||||
'glm-5.1': 200_000,
|
||||
'glm-5': 200_000,
|
||||
'glm-5-turbo': 200_000,
|
||||
@ -36,6 +37,7 @@ const PATTERN_MODEL_CONTEXT_WINDOWS: Array<[RegExp, number]> = [
|
||||
[/^openai\/gpt-5(?:[.-]\d+)?\b/i, 400_000],
|
||||
[/^google\/gemini-(?:2\.0|2\.5|3)/i, 1_048_576],
|
||||
[/^gemini-(?:2\.0|2\.5|3)/i, 1_048_576],
|
||||
[/^zai-org\/glm-5\.2\b/i, 200_000],
|
||||
[/^qwen\/qwen3\.5-(?:plus|flash)\b/i, 1_000_000],
|
||||
[/^qwen3\.5-(?:plus|flash)\b/i, 1_000_000],
|
||||
[/^qwen\/qwen3-max\b/i, 262_144],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user