diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index a683e8a3..6ed5c199 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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 } } +function listen(server: http.Server, host = '127.0.0.1'): Promise { + 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 { + 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) + } + }) }) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index f56da799..f3ae6295 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -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 { 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 { - 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 { + 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 { diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 2f2e9666..3c7caaa1 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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) => void) | null = null + + readAsDataURL(file: Blob) { + pendingReaders.push({ reader: this, file }) + } + } + const pendingReaders: Array<{ reader: DeferredFileReader; file: Blob }> = [] + vi.stubGlobal('FileReader', DeferredFileReader) + + render() + + 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) + }) + + 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', diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 5ee8e762..0aff84d1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -116,6 +116,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const previousActiveTabIdRef = useRef(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, { diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index bb4cb2f0..866be1b0 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -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() + + 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({ diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 31e0f92d..9c75579f 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -975,6 +975,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { } `} aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined} + title={session.title || 'Untitled'} > {isBatchMode ? ( diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts index 95751d81..8f0737da 100644 --- a/src/server/__tests__/provider-presets.test.ts +++ b/src/server/__tests__/provider-presets.test.ts @@ -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') diff --git a/src/server/__tests__/proxy-transform.test.ts b/src/server/__tests__/proxy-transform.test.ts index b4ce8b52..a4580af3 100644 --- a/src/server/__tests__/proxy-transform.test.ts +++ b/src/server/__tests__/proxy-transform.test.ts @@ -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', diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index 86a5cbe4..2cf21e80 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -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 diff --git a/src/server/proxy/transform/anthropicToOpenaiChat.ts b/src/server/proxy/transform/anthropicToOpenaiChat.ts index f1dcf012..76c96658 100644 --- a/src/server/proxy/transform/anthropicToOpenaiChat.ts +++ b/src/server/proxy/transform/anthropicToOpenaiChat.ts @@ -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) { diff --git a/src/server/proxy/transform/anthropicToOpenaiResponses.ts b/src/server/proxy/transform/anthropicToOpenaiResponses.ts index 816f16b7..a8544adb 100644 --- a/src/server/proxy/transform/anthropicToOpenaiResponses.ts +++ b/src/server/proxy/transform/anthropicToOpenaiResponses.ts @@ -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) { diff --git a/src/services/compact/autoCompact.test.ts b/src/services/compact/autoCompact.test.ts index 98e49ae5..9bf6a15f 100644 --- a/src/services/compact/autoCompact.test.ts +++ b/src/services/compact/autoCompact.test.ts @@ -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) diff --git a/src/utils/model/modelContextWindows.ts b/src/utils/model/modelContextWindows.ts index 6c9ec33b..421e2ba3 100644 --- a/src/utils/model/modelContextWindows.ts +++ b/src/utils/model/modelContextWindows.ts @@ -19,6 +19,7 @@ const DIRECT_MODEL_CONTEXT_WINDOWS: Record = { '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],