diff --git a/adapters/common/__tests__/ws-bridge.test.ts b/adapters/common/__tests__/ws-bridge.test.ts index 7d9d040d..d859fbf3 100644 --- a/adapters/common/__tests__/ws-bridge.test.ts +++ b/adapters/common/__tests__/ws-bridge.test.ts @@ -2,6 +2,18 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import { WsBridge } from '../ws-bridge.js' import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws' +async function waitFor( + predicate: () => boolean, + timeoutMs = 500, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return predicate() +} + describe('WsBridge', () => { let bridge: WsBridge @@ -58,8 +70,16 @@ describe('WsBridge', () => { expect(bridge.hasSession('a')).toBe(false) expect(bridge.hasSession('b')).toBe(false) - await new Promise((resolve) => setTimeout(resolve, 20)) for (const ws of sockets) { + const settled = await waitFor(() => ( + ws.readyState === ws.CLOSED + && ws.listenerCount('open') === 0 + && ws.listenerCount('error') === 0 + && ws.listenerCount('close') === 0 + )) + expect(settled).toBe(true) + expect(ws.readyState).toBe(ws.CLOSED) + expect(ws.listenerCount('open')).toBe(0) expect(ws.listenerCount('error')).toBe(0) expect(ws.listenerCount('close')).toBe(0) } diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index b00122dd..288b710f 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -242,9 +242,31 @@ export class WsBridge { ws.removeAllListeners() if (ws.readyState === WebSocket.CLOSED) return - // `ws.close()` aborts an in-flight handshake by emitting an asynchronous - // error before close. Keep a temporary sink after detaching the session - // listeners so teardown cannot surface an unhandled EventEmitter error. + if (ws.readyState === WebSocket.CONNECTING) { + // Bun's `ws` compatibility layer can remain stuck in CLOSING when a + // handshake is aborted. Let the handshake settle, consuming its natural + // error, and close normally if the connection opens first. + const cleanup = () => { + ws.removeListener('open', onOpen) + ws.removeListener('error', onError) + ws.removeListener('close', onClose) + } + const onOpen = () => { + ws.removeListener('open', onOpen) + ws.close(code, reason) + } + const onError = () => cleanup() + const onClose = () => cleanup() + + ws.once('open', onOpen) + ws.once('error', onError) + ws.once('close', onClose) + return + } + + // Keep a temporary error sink after detaching the session listeners so a + // close-time transport error cannot surface as an unhandled EventEmitter + // error. const swallowTeardownError = () => {} ws.on('error', swallowTeardownError) ws.once('close', () => { diff --git a/desktop/electron/services/petWindow.test.ts b/desktop/electron/services/petWindow.test.ts index e8d9c44e..c2ba26b8 100644 --- a/desktop/electron/services/petWindow.test.ts +++ b/desktop/electron/services/petWindow.test.ts @@ -18,6 +18,7 @@ const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts' ? process.cwd() : path.resolve(process.cwd(), 'desktop') const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8') + .replace(/\r\n/g, '\n') function createFakeWindow(initialBounds = { x: 100, @@ -430,14 +431,14 @@ describe('Electron pet window service', () => { expect(secondWindow.showInactive).toHaveBeenCalledTimes(1) }) - it('uses native shapes off macOS and rejects IPC from another window', async () => { + it('keeps the shaped Windows pet topmost and rejects IPC from another window', async () => { const petWindow = createFakeWindow() const otherWindow = createFakeWindow() const controller = new PetWindowController({ createWindow: vi.fn(() => petWindow) as never, getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }), load: vi.fn().mockResolvedValue(undefined), - platform: 'linux', + platform: 'win32', preloadPath: '/app/electron-dist/preload.cjs', }) @@ -448,8 +449,10 @@ describe('Electron pet window service', () => { controller.setIgnoreMouseEvents(petWindow as never, true) expect(petWindow.setShape).toHaveBeenLastCalledWith([ - { x: 100, y: 220, width: 144, height: 170 }, + { x: 88, y: 208, width: 168, height: 192 }, ]) + expect(petWindow.setAlwaysOnTop).toHaveBeenCalledWith(true) + expect(petWindow.setAlwaysOnTop).toHaveBeenLastCalledWith(true) expect(petWindow.setIgnoreMouseEvents).toHaveBeenCalledTimes(1) expect(() => controller.setInteractiveRegions(otherWindow as never, [ { x: 0, y: 0, width: 10, height: 10 }, diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index 035b17cb..7a7fc20e 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -25,6 +25,7 @@ export const PET_WINDOW_STATE_FILE = 'pet-window.json' const MAX_ABSOLUTE_SCREEN_COORDINATE = 1_000_000 const PET_WINDOW_DRAG_INTERVAL_MS = 16 +const PET_WINDOW_SHAPE_PADDING = 12 const failedPetWindowStateWritePaths = new Set() type PetWindow = BrowserWindow @@ -278,6 +279,11 @@ export class PetWindowController { window.setIgnoreMouseEvents(true, { forward: true }) } window.showInactive() + if ((this.options.platform ?? process.platform) === 'darwin') { + window.setAlwaysOnTop(true, 'floating') + } else { + window.setAlwaysOnTop(true) + } } } @@ -311,11 +317,15 @@ export class PetWindowController { if ((this.options.platform ?? process.platform) === 'darwin') return const shape = regions.flatMap((region) => { - const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, Math.round(region.x))) - const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, Math.round(region.y))) - const width = Math.min(PET_WINDOW_WIDTH - x, Math.max(1, Math.round(region.width))) - const height = Math.min(PET_WINDOW_HEIGHT - y, Math.max(1, Math.round(region.height))) - return width > 0 && height > 0 ? [{ x, y, width, height }] : [] + const requestedLeft = Math.round(region.x) - PET_WINDOW_SHAPE_PADDING + const requestedTop = Math.round(region.y) - PET_WINDOW_SHAPE_PADDING + const requestedRight = Math.round(region.x + region.width) + PET_WINDOW_SHAPE_PADDING + const requestedBottom = Math.round(region.y + region.height) + PET_WINDOW_SHAPE_PADDING + const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, requestedLeft)) + const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, requestedTop)) + const right = Math.max(x + 1, Math.min(PET_WINDOW_WIDTH, requestedRight)) + const bottom = Math.max(y + 1, Math.min(PET_WINDOW_HEIGHT, requestedBottom)) + return [{ x, y, width: right - x, height: bottom - y }] }) if (shape.length > 0) window.setShape(shape) } diff --git a/desktop/electron/services/pets.test.ts b/desktop/electron/services/pets.test.ts index aa2d4a35..5717e180 100644 --- a/desktop/electron/services/pets.test.ts +++ b/desktop/electron/services/pets.test.ts @@ -646,7 +646,9 @@ describe('loadCustomPets', () => { ['unsupported image format', { spritesheetPath: 'spritesheet.gif' }, 'unsupported-image-format'], ])('rejects %s', async (_label, overrides, code) => { const root = makeTempDir() - writePet(root, 'bad-pet', validManifest(overrides)) + const petDir = path.join(root, 'bad-pet') + fs.mkdirSync(petDir) + fs.writeFileSync(path.join(petDir, 'pet.json'), JSON.stringify(validManifest(overrides))) const result = await loadCustomPets({ root, inspectImageSize: validSizeInspector }) diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index 65d39d57..33bb8891 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -109,17 +109,21 @@ describe('Electron sidecar manager', () => { }) it('can keep sidecar binaries and H5 assets unpacked while pointing app-root at app.asar', () => { + const resourcesRoot = path.resolve(path.sep, 'Applications', 'App.app', 'Contents', 'Resources') + const desktopRoot = path.join(resourcesRoot, 'app.asar.unpacked') + const appRoot = path.join(resourcesRoot, 'app.asar') + const h5DistDir = path.join(desktopRoot, 'dist') const plan = createServerPlan({ - desktopRoot: '/Applications/App.app/Contents/Resources/app.asar.unpacked', - appRoot: '/Applications/App.app/Contents/Resources/app.asar', - h5DistDir: '/Applications/App.app/Contents/Resources/app.asar.unpacked/dist', + desktopRoot, + appRoot, + h5DistDir, port: 49321, env: {}, }) - expect(plan.command).toContain('/Applications/App.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-') - expect(plan.args).toContain('/Applications/App.app/Contents/Resources/app.asar') - expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar.unpacked/dist') + expect(plan.command).toContain(path.join(desktopRoot, 'src-tauri', 'binaries', 'claude-sidecar-')) + expect(plan.args).toContain(appRoot) + expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(h5DistDir) }) it('passes the packaged ripgrep path to the server and its CLI children', () => { @@ -137,7 +141,9 @@ describe('Electron sidecar manager', () => { }) expect(plan.env[RIPGREP_PATH_ENV]).toBe(bundledRipgrep) - expect(plan.env.PATH?.split(path.delimiter)).toContain( + const pathValue = Object.entries(plan.env) + .find(([key]) => key.toLowerCase() === 'path')?.[1] + expect(pathValue?.split(path.delimiter)).toContain( path.dirname(bundledRipgrep), ) @@ -555,4 +561,25 @@ describe('Electron sidecar manager', () => { await close(server) } }) + + it('isolates health probes from the shared HTTP connection pool', async () => { + const connections = new Set() + const connectionHeaders: string[] = [] + const server = http.createServer((request, response) => { + connections.add(request.socket.remotePort!) + connectionHeaders.push(request.headers.connection ?? '') + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ status: 'ok' })) + }) + const port = await listen(server) + + try { + await waitForServer('127.0.0.1', port, 1_000) + await waitForServer('127.0.0.1', port, 1_000) + expect(connections.size).toBe(2) + expect(connectionHeaders).toEqual(['close', 'close']) + } finally { + await close(server) + } + }) }) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index d194082f..e7483177 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -12,6 +12,7 @@ import { writeFileSync, } from 'node:fs' import type { Readable } from 'node:stream' +import http from 'node:http' import net from 'node:net' import os from 'node:os' import path from 'node:path' @@ -277,27 +278,46 @@ export async function waitForServer(host: string, port: number, timeoutMs = SERV } 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, + await new Promise((resolve, reject) => { + const request = http.get(healthUrl, { + agent: false, + headers: { + Accept: 'application/json', + Connection: 'close', + }, + }, response => { + const chunks: Buffer[] = [] + response.on('data', chunk => chunks.push(Buffer.from(chunk))) + response.on('error', reject) + response.on('end', () => { + if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`healthcheck returned ${response.statusCode ?? 'no status'}`)) + return + } + + const contentType = response.headers['content-type'] ?? '' + if (!contentType.toLowerCase().includes('application/json')) { + reject(new Error(`healthcheck returned non-JSON response from ${healthUrl}`)) + return + } + + try { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown + if (!body || typeof body !== 'object' || !('status' in body) || body.status !== 'ok') { + reject(new Error(`healthcheck returned invalid response from ${healthUrl}`)) + return + } + resolve() + } catch { + reject(new Error(`healthcheck returned invalid response from ${healthUrl}`)) + } + }) }) - 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) - } + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`)) + }) + request.on('error', reject) + }) } function sleep(ms: number): Promise { diff --git a/desktop/electron/services/systemProxyBridge.ts b/desktop/electron/services/systemProxyBridge.ts index fead7468..6f47b20e 100644 --- a/desktop/electron/services/systemProxyBridge.ts +++ b/desktop/electron/services/systemProxyBridge.ts @@ -79,11 +79,15 @@ export class SystemProxyBridge implements SystemProxyBridgeLike { this.startPromise = null const server = this.server this.server = null + for (const socket of this.clientSockets) socket.destroy() + for (const socket of this.outboundSockets) socket.destroy() + // Bun needs the HTTP connection registry drained before close() will + // finish after an upgraded CONNECT socket. Calling this before listen + // completes breaks the separate startup/stop race, so guard on listening. + if (server?.listening) server.closeAllConnections() const closing = server?.listening ? new Promise(resolve => server.close(() => resolve())) : Promise.resolve() - for (const socket of this.clientSockets) socket.destroy() - for (const socket of this.outboundSockets) socket.destroy() await closing await startPromise?.catch(() => {}) } diff --git a/desktop/electron/services/terminal.test.ts b/desktop/electron/services/terminal.test.ts index ba833b5e..3b6ca050 100644 --- a/desktop/electron/services/terminal.test.ts +++ b/desktop/electron/services/terminal.test.ts @@ -74,8 +74,10 @@ describe('Electron terminal service', () => { it('uses the custom terminal config path before the standard ~/.claude path', () => { const app = { getPath: vi.fn(() => '/Users/test') } - expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json') - expect(terminalConfigPath(app, {})).toBe('/Users/test/.claude/terminal-config.json') + expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })) + .toBe(path.join('/portable', 'terminal-config.json')) + expect(terminalConfigPath(app, {})) + .toBe(path.join('/Users/test', '.claude', 'terminal-config.json')) }) it('reads an old userData terminal config but writes future changes to ~/.claude', () => { @@ -181,8 +183,10 @@ describe('Electron terminal service', () => { expect(prepareNodePtyRuntime(source, cache)).toBe(cache) expect(fs.existsSync(path.join(cache, 'index.js'))).toBe(true) - expect(fs.statSync(cache).mode & 0o077).toBe(0) - expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500) + if (process.platform !== 'win32') { + expect(fs.statSync(cache).mode & 0o077).toBe(0) + expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500) + } expect(fs.existsSync(path.join(cache, '.cc-haha-node-pty-manifest.json'))).toBe(true) }) diff --git a/desktop/electron/services/tray.test.ts b/desktop/electron/services/tray.test.ts index a4313581..ee61e1a3 100644 --- a/desktop/electron/services/tray.test.ts +++ b/desktop/electron/services/tray.test.ts @@ -1,28 +1,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { installTray, resolveTrayIconPath, shouldInstallTray } from './tray' -const trayMocksKey = '__electronTrayMocks' - -vi.mock('electron', () => { - const mocks = (globalThis as Record)[trayMocksKey] as ReturnType | undefined - if (!mocks) { - throw new Error('Electron tray mocks were not initialized for this test') - } - - return { - Menu: { - buildFromTemplate: mocks.buildFromTemplate, - }, - Tray: mocks.Tray.mockImplementation(() => mocks.tray), - nativeImage: { - createFromPath: mocks.createFromPath, - }, - } -}) - function createElectronTrayMocks() { const handlers = new Map void>() return { @@ -42,10 +23,6 @@ function createElectronTrayMocks() { } describe('Electron tray service', () => { - afterEach(() => { - delete (globalThis as Record)[trayMocksKey] - }) - it('uses the existing desktop icon assets for the tray icon', () => { const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-')) try { @@ -78,7 +55,6 @@ describe('Electron tray service', () => { const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-install-')) try { const trayMocks = createElectronTrayMocks() - ;(globalThis as Record)[trayMocksKey] = trayMocks const iconPath = path.join(root, 'src-tauri', 'icons', 'icon.png') mkdirSync(path.dirname(iconPath), { recursive: true }) writeFileSync(iconPath, 'png') @@ -90,6 +66,15 @@ describe('Electron tray service', () => { desktopRoot: root, show, quit, + electronRuntime: { + Menu: { + buildFromTemplate: trayMocks.buildFromTemplate, + }, + Tray: trayMocks.Tray.mockImplementation(() => trayMocks.tray), + nativeImage: { + createFromPath: trayMocks.createFromPath, + }, + } as never, }) expect(trayMocks.createFromPath).toHaveBeenCalledWith(iconPath) diff --git a/desktop/electron/services/tray.ts b/desktop/electron/services/tray.ts index fc85dd85..09b5896d 100644 --- a/desktop/electron/services/tray.ts +++ b/desktop/electron/services/tray.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs' import path from 'node:path' import type { App, Tray } from 'electron' +type ElectronTrayRuntime = Pick + export type TrayController = { tray: Tray dispose(): void @@ -29,13 +31,15 @@ export async function installTray({ desktopRoot, show, quit, + electronRuntime, }: { app: App desktopRoot: string show: () => void quit: () => void + electronRuntime?: ElectronTrayRuntime }): Promise { - const { Menu, Tray, nativeImage } = await import('electron') + const { Menu, Tray, nativeImage } = electronRuntime ?? await import('electron') const icon = nativeImage.createFromPath(resolveTrayIconPath(desktopRoot)) const tray = new Tray(icon) tray.setToolTip(app.name || 'Claude Code Haha') diff --git a/desktop/scripts/electron-output-guard.test.ts b/desktop/scripts/electron-output-guard.test.ts index f2a250b7..9858369b 100644 --- a/desktop/scripts/electron-output-guard.test.ts +++ b/desktop/scripts/electron-output-guard.test.ts @@ -9,7 +9,26 @@ import { parseProcessSnapshots, } from './electron-output-guard' -const outputDir = '/workspace/desktop/build-artifacts/electron' +const outputDir = path.resolve('build-artifacts', 'electron') +const bundledMainCommand = path.join( + outputDir, + 'mac-arm64', + 'Claude Code Haha.app', + 'Contents', + 'MacOS', + 'Claude Code Haha', +) +const bundledSidecarCommand = path.join( + outputDir, + 'mac-arm64', + 'Claude Code Haha.app', + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'src-tauri', + 'binaries', + 'claude-sidecar server', +) const scriptsDir = import.meta.dirname describe('Electron output guard', () => { @@ -31,11 +50,11 @@ describe('Electron output guard', () => { const processes = findRunningElectronOutputProcesses(outputDir, [ { pid: 20641, - command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha', + command: bundledMainCommand, }, { pid: 20656, - command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar server', + command: bundledSidecarCommand, }, { pid: 99, @@ -46,11 +65,11 @@ describe('Electron output guard', () => { expect(processes).toEqual([ { pid: 20641, - command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha', + command: bundledMainCommand, }, { pid: 20656, - command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar server', + command: bundledSidecarCommand, }, ]) }) @@ -58,7 +77,7 @@ describe('Electron output guard', () => { it('fails before cleanup with an actionable message', () => { expect(() => assertElectronOutputIdle(outputDir, [{ pid: 20641, - command: '/workspace/desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha', + command: bundledMainCommand, }])).toThrow(/Quit the packaged app before rebuilding.*PID 20641/s) }) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index e7398345..97a50bf9 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -773,7 +773,7 @@ describe('Content-only pages render without errors', () => { expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { includeContext: true, contextOnly: true, - timeout: 20_000, + timeout: 30_000, }) resetPageStores() diff --git a/desktop/src/api/client.test.ts b/desktop/src/api/client.test.ts index 548ba4a2..90f63f89 100644 --- a/desktop/src/api/client.test.ts +++ b/desktop/src/api/client.test.ts @@ -145,10 +145,9 @@ describe('api diagnostics reporting', () => { }) } - return Promise.resolve(new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })) + return Promise.resolve({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as Response) }) const request = expect(api.get('/api/slow')).rejects.toThrow('Request timed out after 120s') @@ -163,6 +162,36 @@ describe('api diagnostics reporting', () => { expect(body.details.message).toBe('Request timed out after 120s') }) + it('keeps the timeout active until the response body is consumed', async () => { + let requestSignal: AbortSignal | undefined + let bodyReadStarted = false + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockImplementation((url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/api/slow-body')) { + requestSignal = init?.signal ?? undefined + return Promise.resolve({ + ok: true, + status: 200, + json: () => new Promise((_resolve, reject) => { + bodyReadStarted = true + requestSignal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')) + }) + }), + } as Response) + } + + return Promise.resolve({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as Response) + }) + + await expect(api.get('/api/slow-body', { timeout: 20 })).rejects.toThrow('Request timed out after 0s') + + expect(bodyReadStarted).toBe(true) + expect(requestSignal?.aborted).toBe(true) + }) + it('can report raw client exceptions', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { @@ -185,6 +214,36 @@ describe('api diagnostics reporting', () => { expect(body.type).toBe('client_window_error') }) + it('drains diagnostics response bodies before releasing the request', async () => { + let resolveBody!: () => void + const bodyConsumed = new Promise(resolve => { + resolveBody = resolve + }) + const arrayBuffer = vi.fn(async () => { + await bodyConsumed + return new ArrayBuffer(0) + }) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce({ arrayBuffer } as unknown as Response) + + let settled = false + const request = rawRecordDiagnosticEvent({ + type: 'client_window_error', + severity: 'error', + summary: 'drain me', + }).then(() => { + settled = true + }) + + await Promise.resolve() + expect(arrayBuffer).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + resolveBody() + await request + expect(settled).toBe(true) + }) + it('bounds raw diagnostics requests when the local server is unresponsive', async () => { vi.useFakeTimers() let signal: AbortSignal | undefined diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 76297f22..5566faaf 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -101,7 +101,7 @@ async function request(method: string, path: string, body?: unknown, options? } if (res.status === 204) return undefined as T - return res.json() as Promise + return await res.json() as T } catch (err) { if (timedOut) { const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) @@ -158,6 +158,8 @@ export function rawRecordDiagnosticEvent(event: { headers: buildHeaders(), body: JSON.stringify(event), signal: controller.signal, + }).then(async response => { + await response.arrayBuffer() }) .catch(() => undefined) .finally(() => clearTimeout(timeout)) diff --git a/desktop/src/api/desktopUiPreferences.test.ts b/desktop/src/api/desktopUiPreferences.test.ts index 693e7865..b5e283f9 100644 --- a/desktop/src/api/desktopUiPreferences.test.ts +++ b/desktop/src/api/desktopUiPreferences.test.ts @@ -14,6 +14,7 @@ const preferences = { enabled: false, selectedPetId: 'dada-code', size: 144, + showTaskPanel: false, collapsed: false, motionEnabled: true, lastSessionId: null, diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts index c55589d5..08af748c 100644 --- a/desktop/src/api/desktopUiPreferences.ts +++ b/desktop/src/api/desktopUiPreferences.ts @@ -19,6 +19,7 @@ export type DesktopPetPreferences = { enabled: boolean selectedPetId: string size: number + showTaskPanel: boolean collapsed: boolean motionEnabled: boolean lastSessionId: string | null diff --git a/desktop/src/components/chat/ContextUsageIndicator.test.tsx b/desktop/src/components/chat/ContextUsageIndicator.test.tsx index 00295a28..a3f99daa 100644 --- a/desktop/src/components/chat/ContextUsageIndicator.test.tsx +++ b/desktop/src/components/chat/ContextUsageIndicator.test.tsx @@ -109,7 +109,7 @@ describe('ContextUsageIndicator request behavior', () => { expect(sessionsApiMock.getInspection).toHaveBeenCalledWith('session-1', { includeContext: true, contextOnly: true, - timeout: 20_000, + timeout: 30_000, }) }) diff --git a/desktop/src/components/chat/ContextUsageIndicator.tsx b/desktop/src/components/chat/ContextUsageIndicator.tsx index f907094e..0569382f 100644 --- a/desktop/src/components/chat/ContextUsageIndicator.tsx +++ b/desktop/src/components/chat/ContextUsageIndicator.tsx @@ -21,7 +21,10 @@ type Props = { } const ACTIVE_REFRESH_MS = 30_000 -const CONTEXT_REQUEST_TIMEOUT_MS = 20_000 +// The server bounds the CLI control request at 20s. Keep the HTTP deadline +// comfortably later so the server can return a transcript estimate instead of +// racing a client abort that can strand loopback sockets on Windows. +const CONTEXT_REQUEST_TIMEOUT_MS = 30_000 const AUTO_REFRESH_MIN_INTERVAL_MS = 10_000 // Right after a compaction the CLI may still be busy finishing the turn, so // the forced refresh can time out — retry once instead of keeping the stale diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 1ddd5409..822a4ccf 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -99,6 +99,7 @@ vi.mock('../../i18n', () => ({ 'tabs.hideWorkspace': 'Hide Workspace', 'tabs.showBrowser': 'Show Browser', 'tabs.hideBrowser': 'Hide Browser', + 'settings.title': 'Localized Settings', 'openProject.openProject': 'Open project', 'openProject.openIn': 'Open in {target}', 'openProject.openFailed': 'Could not open project', @@ -537,6 +538,23 @@ describe('TabBar', () => { expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument() }) + it('renders the settings tab title from the current locale instead of its persisted title', async () => { + const { TabBar } = await import('./TabBar') + const { SETTINGS_TAB_ID, useTabStore } = await import('../../stores/tabStore') + + useTabStore.setState({ + tabs: [{ sessionId: SETTINGS_TAB_ID, title: '设置', type: 'settings', status: 'idle' }], + activeTabId: SETTINGS_TAB_ID, + }) + + await act(async () => { + render() + }) + + expect(screen.getByText('Localized Settings')).toBeInTheDocument() + expect(screen.queryByText('设置')).not.toBeInTheDocument() + }) + it('shows current-session CLI tasks without a numeric activity badge', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index b0e44528..f48fe6f7 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -418,6 +418,7 @@ export function TabBar() { key={tab.sessionId} ref={(node) => { tabRefs.current.set(tab.sessionId, node) }} tab={tab} + displayTitle={tab.type === 'settings' ? t('settings.title') : tab.title} isRunning={runningSessionIds.has(tab.sessionId)} isActive={tab.sessionId === activeTabId} isDragOver={dragOverIndex === index} @@ -568,6 +569,7 @@ export function TabBar() { const TabItem = forwardRef void onContextMenu: (e: React.MouseEvent) => void onMouseDown: (event: React.MouseEvent) => void -}>(({ tab, isRunning, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => { +}>(({ tab, displayTitle, isRunning, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => { return (
- {tab.title || 'Untitled'} + {displayTitle || 'Untitled'} - -
void getDesktopHost().pets.setIgnoreMouseEvents(false)} - onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)} - > - {actionError && ( -

- {actionError} -

+ dragGestureRef.current = { + pointerId: event.pointerId, + startScreenX: event.screenX, + startScreenY: event.screenY, + directionScreenX: event.screenX, + lastScreenX: event.screenX, + lastScreenY: event.screenY, + startPromise: null, + } + void getDesktopHost().pets.setIgnoreMouseEvents(false) + try { + event.currentTarget.setPointerCapture(event.pointerId) + } catch { + // Pointer capture can fail when the pointer has already been cancelled. + } + }} + onPointerMove={(event) => { + const gesture = dragGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId || (event.buttons & 1) === 0) return + gesture.lastScreenX = event.screenX + gesture.lastScreenY = event.screenY + if (!gesture.startPromise) { + const distance = Math.hypot( + event.screenX - gesture.startScreenX, + event.screenY - gesture.startScreenY, + ) + if (distance < PET_DRAG_THRESHOLD_PX) return + suppressNextMascotClickRef.current = true + setIsMascotDragging(true) + gesture.startPromise = Promise.resolve().then(() => + getDesktopHost().pets.dragWindow({ + phase: 'start', + x: gesture.startScreenX, + y: gesture.startScreenY, + })) + void gesture.startPromise.catch(() => undefined) + } + const directionDelta = event.screenX - gesture.directionScreenX + if (Math.abs(directionDelta) >= PET_DRAG_THRESHOLD_PX) { + setDragDirection(directionDelta < 0 ? 'left' : 'right') + gesture.directionScreenX = event.screenX + } + event.preventDefault() + }} + onPointerUp={(event) => finishMascotDrag(event, true)} + onPointerCancel={(event) => finishMascotDrag(event, true)} + onLostPointerCapture={(event) => finishMascotDrag(event, false)} + onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)} + > + + + {activities.length > 0 && !showActivityCard && ( + )} +
+ + {showActivityCard && ( +
void getDesktopHost().pets.setIgnoreMouseEvents(false)} + onMouseLeave={(event) => releasePointerPassthrough(event.relatedTarget)} + > + {actionError && ( +

+ {actionError} +

+ )} - {activities.length > 0 ? (
- {activities.map((activity, index) => { + {activities.map((activity) => { const title = activity.session.title || t('pet.window.untitledSession') const status = t(`pet.window.status.${activity.status}` as Parameters[0]) - const hiddenWhileCollapsed = !expanded && index > 0 return (
) })}
- ) : ( -
- {t('pet.window.noSessions')} -
- )} - -
+ + + )} ) diff --git a/desktop/src/features/pets/PetSettings.test.tsx b/desktop/src/features/pets/PetSettings.test.tsx index 1e9804d7..023f65fe 100644 --- a/desktop/src/features/pets/PetSettings.test.tsx +++ b/desktop/src/features/pets/PetSettings.test.tsx @@ -58,6 +58,7 @@ const defaultPetPreferences: DesktopPetPreferences = { enabled: false, selectedPetId: 'dada-code', size: 144, + showTaskPanel: false, collapsed: false, motionEnabled: true, lastSessionId: null, @@ -67,7 +68,7 @@ function preferencesResponse(pet: DesktopPetPreferences) { return { exists: true, preferences: { - schemaVersion: 3, + schemaVersion: 4, sidebar: { projectOrder: [], pinnedProjects: [], @@ -156,6 +157,20 @@ describe('PetSettings', () => { expect(hidePetMock).not.toHaveBeenCalled() }) + it('keeps the task panel disabled by default and persists enabling it', async () => { + render() + + const panelToggle = await screen.findByRole('checkbox', { name: 'Show active task panel' }) + expect(panelToggle).not.toBeChecked() + expect(screen.queryByRole('checkbox', { name: 'Start collapsed' })).not.toBeInTheDocument() + + fireEvent.click(panelToggle) + + await waitFor(() => { + expect(updatePetPreferencesMock).toHaveBeenCalledWith({ showTaskPanel: true }) + }) + }) + it('rolls back optimistic preferences when persistence fails', async () => { updatePetPreferencesMock.mockRejectedValueOnce(new Error('disk full')) render() @@ -232,12 +247,12 @@ describe('PetSettings', () => { fireEvent.change(screen.getByRole('slider', { name: 'Pet size' }), { target: { value: '176' } }) fireEvent.click(screen.getByRole('checkbox', { name: 'Play animations' })) - fireEvent.click(screen.getByRole('checkbox', { name: 'Start collapsed' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Show active task panel' })) await waitFor(() => { expect(updatePetPreferencesMock).toHaveBeenCalledWith({ size: 176 }) expect(updatePetPreferencesMock).toHaveBeenCalledWith({ motionEnabled: false }) - expect(updatePetPreferencesMock).toHaveBeenCalledWith({ collapsed: true }) + expect(updatePetPreferencesMock).toHaveBeenCalledWith({ showTaskPanel: true }) }) }) diff --git a/desktop/src/features/pets/PetSettings.tsx b/desktop/src/features/pets/PetSettings.tsx index 1d6fad31..5ecd5a7f 100644 --- a/desktop/src/features/pets/PetSettings.tsx +++ b/desktop/src/features/pets/PetSettings.tsx @@ -347,10 +347,10 @@ export function PetSettings() {
void updatePreferences({ collapsed: checked })} + label={t('settings.pets.showTaskPanel')} + description={t('settings.pets.showTaskPanelDescription')} + checked={preferences.showTaskPanel} + onChange={(checked) => void updatePreferences({ showTaskPanel: checked })} />
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d72ad0eb..a28441d1 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -287,6 +287,8 @@ export const en = { 'settings.pets.sizeDescription': 'Adjust the pet between 96 and 192 pixels.', 'settings.pets.motion': 'Play animations', 'settings.pets.motionDescription': 'Turn off movement while keeping the pet available.', + 'settings.pets.showTaskPanel': 'Show active task panel', + 'settings.pets.showTaskPanelDescription': 'Show the rectangular task panel while tasks are running. It stays hidden when no task is active.', 'settings.pets.collapsed': 'Start collapsed', 'settings.pets.collapsedDescription': 'Show only the pet until you open its task panel.', 'settings.pets.folderTitle': 'Custom pet folder', @@ -313,6 +315,8 @@ export const en = { 'pet.window.status.running': 'Working', 'pet.window.status.idle': 'Idle', 'pet.window.sessionCount': '{count} tasks', + 'pet.window.expandTasks': 'Show {count} active tasks', + 'pet.window.hideTasks': 'Hide {count} active tasks', 'pet.window.untitledSession': 'Untitled task', // Settings > Usage diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 726eb4de..942bfaae 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -289,6 +289,8 @@ export const jp: Record = { 'settings.pets.sizeDescription': '96〜192ピクセルの範囲で調整します。', 'settings.pets.motion': 'アニメーションを再生', 'settings.pets.motionDescription': 'オフにしてもペットは利用できますが、動きません。', + 'settings.pets.showTaskPanel': '実行中のタスクパネルを表示', + 'settings.pets.showTaskPanelDescription': 'タスクの実行中だけ長方形のパネルを表示します。実行中のタスクがなければ常に非表示です。', 'settings.pets.collapsed': '最初は折りたたむ', 'settings.pets.collapsedDescription': 'タスクパネルを開くまでペットだけを表示します。', 'settings.pets.folderTitle': 'カスタムペットフォルダー', @@ -315,6 +317,8 @@ export const jp: Record = { 'pet.window.status.running': '作業中', 'pet.window.status.idle': '待機中', 'pet.window.sessionCount': '{count} 件のタスク', + 'pet.window.expandTasks': '実行中のタスク {count} 件を表示', + 'pet.window.hideTasks': '実行中のタスク {count} 件を隠す', 'pet.window.untitledSession': '無題のタスク', // Settings > Usage diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index e68b5aff..0f06c9cf 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -289,6 +289,8 @@ export const kr: Record = { 'settings.pets.sizeDescription': '96~192픽셀 사이에서 조절합니다.', 'settings.pets.motion': '애니메이션 재생', 'settings.pets.motionDescription': '꺼도 펫은 사용할 수 있지만 움직이지 않습니다.', + 'settings.pets.showTaskPanel': '진행 중인 작업 패널 표시', + 'settings.pets.showTaskPanelDescription': '작업이 실행 중일 때만 사각형 작업 패널을 표시합니다. 진행 중인 작업이 없으면 항상 숨깁니다.', 'settings.pets.collapsed': '접힌 상태로 시작', 'settings.pets.collapsedDescription': '작업 패널을 열기 전에는 펫만 표시합니다.', 'settings.pets.folderTitle': '사용자 지정 펫 폴더', @@ -315,6 +317,8 @@ export const kr: Record = { 'pet.window.status.running': '작업 중', 'pet.window.status.idle': '대기 중', 'pet.window.sessionCount': '작업 {count}개', + 'pet.window.expandTasks': '진행 중인 작업 {count}개 표시', + 'pet.window.hideTasks': '진행 중인 작업 {count}개 숨기기', 'pet.window.untitledSession': '제목 없는 작업', // Settings > Usage diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index dc2e55d4..5d0401f7 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -289,6 +289,8 @@ export const zh: Record = { 'settings.pets.sizeDescription': '可在 96 到 192 像素之間調整。', 'settings.pets.motion': '播放動畫', 'settings.pets.motionDescription': '關閉後寵物仍可使用,但不會移動。', + 'settings.pets.showTaskPanel': '顯示進行中的工作區域', + 'settings.pets.showTaskPanelDescription': '有工作執行時顯示矩形工作區域;沒有進行中的工作時一律隱藏。', 'settings.pets.collapsed': '預設收合', 'settings.pets.collapsedDescription': '預設只顯示寵物,需要時再展開任務面板。', 'settings.pets.folderTitle': '自訂寵物資料夾', @@ -315,6 +317,8 @@ export const zh: Record = { 'pet.window.status.running': '工作中', 'pet.window.status.idle': '閒置', 'pet.window.sessionCount': '{count} 個任務', + 'pet.window.expandTasks': '顯示 {count} 個進行中的任務', + 'pet.window.hideTasks': '隱藏 {count} 個進行中的任務', 'pet.window.untitledSession': '未命名任務', // Settings > Usage diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index cb9990a1..d8c68842 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -289,6 +289,8 @@ export const zh: Record = { 'settings.pets.sizeDescription': '可在 96 到 192 像素之间调整。', 'settings.pets.motion': '播放动画', 'settings.pets.motionDescription': '关闭后宠物仍可使用,但不会移动。', + 'settings.pets.showTaskPanel': '显示进行中的任务区域', + 'settings.pets.showTaskPanelDescription': '有任务运行时显示矩形任务区域;没有进行中的任务时始终隐藏。', 'settings.pets.collapsed': '默认收起', 'settings.pets.collapsedDescription': '默认只显示宠物,需要时再展开任务面板。', 'settings.pets.folderTitle': '自定义宠物文件夹', @@ -315,6 +317,8 @@ export const zh: Record = { 'pet.window.status.running': '工作中', 'pet.window.status.idle': '空闲', 'pet.window.sessionCount': '{count} 个任务', + 'pet.window.expandTasks': '显示 {count} 个进行中的任务', + 'pet.window.hideTasks': '隐藏 {count} 个进行中的任务', 'pet.window.untitledSession': '未命名任务', // Settings > Usage diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index d96566f6..c98b5638 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -1,3 +1,4 @@ +import { StrictMode } from 'react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -141,6 +142,21 @@ describe('TerminalSettings', () => { expect(terminalMocks.fitInstance.fit).toHaveBeenCalled() }) + it('keeps the terminal runtime current across the StrictMode effect replay', async () => { + terminalMocks.available = true + + render( + + + , + ) + + await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1)) + expect(screen.getByText('Running')).toBeInTheDocument() + + destroyTerminalRuntime('strict-mode-terminal') + }) + it('does not start duplicate xterm surfaces for one runtime', async () => { terminalMocks.available = true terminalMocks.terminalInstance.open.mockImplementation((host: HTMLElement) => { diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index 6e04e53e..500c6e86 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -75,6 +75,7 @@ export function TerminalSettings({ const desktopTerminal = useSettingsStore((state) => state.desktopTerminal) const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal) const hostRef = useRef(null) + const lifecycleVersionRef = useRef(0) const localRuntimeIdRef = useRef(null) if (!localRuntimeIdRef.current) { localRuntimeIdRef.current = runtimeId ?? createLocalTerminalRuntimeId() @@ -334,6 +335,8 @@ export function TerminalSettings({ }, [cwd, resizeSession, runtime]) useEffect(() => { + lifecycleVersionRef.current += 1 + const lifecycleVersion = lifecycleVersionRef.current if (!terminalApi.isAvailable()) return if (runtime.terminal) { if (hostRef.current) { @@ -356,7 +359,13 @@ export function TerminalSettings({ return () => { observer.disconnect() if (!preserveOnUnmount) { - destroyTerminalRuntime(runtime.id) + // StrictMode replays effects once during initial mount. Let the replay + // retain this runtime instead of leaving the component with a stale + // object that can never start or restart. + queueMicrotask(() => { + if (lifecycleVersionRef.current !== lifecycleVersion) return + destroyTerminalRuntime(runtime.id) + }) } } }, [preserveOnUnmount, resizeSession, runtime, startTerminal]) diff --git a/desktop/src/stores/agentStore.test.ts b/desktop/src/stores/agentStore.test.ts index 9e710342..291d0f51 100644 --- a/desktop/src/stores/agentStore.test.ts +++ b/desktop/src/stores/agentStore.test.ts @@ -187,6 +187,55 @@ describe('agentStore', () => { }) }) + it('finishes a create while the active session reload is still pending', async () => { + const reload = deferred() + const createdAgent = makeAgent({ target: 'reviewer.md' }) + apiCreateMock.mockResolvedValue({ agent: createdAgent }) + apiListMock.mockResolvedValue({ + activeAgents: [createdAgent], + allAgents: [createdAgent], + }) + apiReloadMock.mockReturnValue(reload.promise) + + await expect( + useAgentStore.getState().createAgent(makeInput(), 'session-1'), + ).resolves.toBe(createdAgent) + + expect(apiReloadMock).toHaveBeenCalledWith('session-1') + expect(useAgentStore.getState()).toMatchObject({ + selectedAgent: createdAgent, + isMutating: false, + mutationWarning: null, + }) + + reload.reject(new Error('Session reload unavailable')) + await vi.waitFor(() => expect(useAgentStore.getState().mutationWarning).toBe( + 'Session reload unavailable', + )) + }) + + it('ignores a late reload warning after switching projects', async () => { + const reload = deferred() + const createdAgent = makeAgent({ target: 'reviewer.md' }) + const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' }) + apiCreateMock.mockResolvedValue({ agent: createdAgent }) + apiListMock + .mockResolvedValueOnce({ activeAgents: [createdAgent], allAgents: [createdAgent] }) + .mockResolvedValueOnce({ activeAgents: [currentAgent], allAgents: [currentAgent] }) + apiReloadMock.mockReturnValue(reload.promise) + + await useAgentStore.getState().createAgent(makeInput(), 'session-1') + await useAgentStore.getState().fetchAgents('/workspace/current-project') + reload.reject(new Error('Old session reload failed')) + await Promise.resolve() + await Promise.resolve() + + expect(useAgentStore.getState()).toMatchObject({ + allAgents: [currentAgent], + mutationWarning: null, + }) + }) + it('does not let a mutation refresh overwrite a later project switch', async () => { const mutationRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>() const currentRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>() @@ -311,6 +360,34 @@ describe('agentStore', () => { expect(useAgentStore.getState().selectedAgent).toBe(updatedAgent) }) + it('finishes an update while the active session reload is still pending', async () => { + const reload = deferred() + const updatedAgent = makeAgent({ description: 'Updated', target: 'reviewer.md' }) + apiUpdateMock.mockResolvedValue({ agent: updatedAgent }) + apiListMock.mockResolvedValue({ + activeAgents: [updatedAgent], + allAgents: [updatedAgent], + }) + apiReloadMock.mockReturnValue(reload.promise) + + await expect(useAgentStore.getState().updateAgent( + 'reviewer', + makeInput({ description: 'Updated' }), + 'session-1', + )).resolves.toBe(updatedAgent) + + expect(useAgentStore.getState()).toMatchObject({ + selectedAgent: updatedAgent, + isMutating: false, + mutationWarning: null, + }) + + reload.reject(new Error('Session reload unavailable')) + await vi.waitFor(() => expect(useAgentStore.getState().mutationWarning).toBe( + 'Session reload unavailable', + )) + }) + it('does not expose an old mutation failure after a new project fetch succeeds', async () => { const mutationRequest = deferred() const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' }) @@ -427,6 +504,32 @@ describe('agentStore', () => { }) }) + it('finishes a delete while the active session reload is still pending', async () => { + const reload = deferred() + apiDeleteMock.mockResolvedValue(undefined) + apiListMock.mockResolvedValue({ activeAgents: [], allAgents: [] }) + apiReloadMock.mockReturnValue(reload.promise) + + await expect(useAgentStore.getState().deleteAgent( + 'reviewer', + 'project', + '/workspace/current', + 'reviewer.md', + 'session-1', + )).resolves.toBeUndefined() + + expect(useAgentStore.getState()).toMatchObject({ + selectedAgent: null, + isMutating: false, + mutationWarning: null, + }) + + reload.reject(new Error('Session reload unavailable')) + await vi.waitFor(() => expect(useAgentStore.getState().mutationWarning).toBe( + 'Session reload unavailable', + )) + }) + it('does not let a delete refresh clear the selection from a later project switch', async () => { const mutationRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>() const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' }) diff --git a/desktop/src/stores/agentStore.ts b/desktop/src/stores/agentStore.ts index 9dd5ce65..3cd7be94 100644 --- a/desktop/src/stores/agentStore.ts +++ b/desktop/src/stores/agentStore.ts @@ -114,7 +114,7 @@ export const useAgentStore = create((set, get) => ({ throw error } - const reloadWarningPromise = getSessionReloadWarning(sessionId) + startSessionReloadWarning(sessionId, requestId, set) try { const response = await agentsApi.list(input.cwd) const refreshedAgent = findEditableAgent( @@ -126,18 +126,15 @@ export const useAgentStore = create((set, get) => ({ if (!refreshedAgent) { throw new Error('Created agent was not returned by the refreshed list') } - const reloadWarning = await reloadWarningPromise if (requestId !== latestFetchRequestId) return refreshedAgent set({ ...response, selectedAgent: refreshedAgent, selectedAgentReturnTab: 'agents', isMutating: false, - mutationWarning: reloadWarning, }) return refreshedAgent } catch (refreshError) { - const reloadWarning = await reloadWarningPromise if (requestId === latestFetchRequestId) { set((state) => ({ ...upsertMutationAgent(state, createdAgent), @@ -149,7 +146,7 @@ export const useAgentStore = create((set, get) => ({ refreshError, 'Failed to refresh agents after creating the agent', ), - reloadWarning, + state.mutationWarning, ), })) } @@ -177,7 +174,7 @@ export const useAgentStore = create((set, get) => ({ throw error } - const reloadWarningPromise = getSessionReloadWarning(sessionId) + startSessionReloadWarning(sessionId, requestId, set) try { const response = await agentsApi.list(input.cwd) const refreshedAgent = findEditableAgent( @@ -189,18 +186,15 @@ export const useAgentStore = create((set, get) => ({ if (!refreshedAgent) { throw new Error('Updated agent was not returned by the refreshed list') } - const reloadWarning = await reloadWarningPromise if (requestId !== latestFetchRequestId) return refreshedAgent set({ ...response, selectedAgent: refreshedAgent, selectedAgentReturnTab: 'agents', isMutating: false, - mutationWarning: reloadWarning, }) return refreshedAgent } catch (refreshError) { - const reloadWarning = await reloadWarningPromise if (requestId === latestFetchRequestId) { set((state) => ({ ...upsertMutationAgent(state, updatedAgent), @@ -212,7 +206,7 @@ export const useAgentStore = create((set, get) => ({ refreshError, 'Failed to refresh agents after updating the agent', ), - reloadWarning, + state.mutationWarning, ), })) } @@ -238,20 +232,17 @@ export const useAgentStore = create((set, get) => ({ throw error } - const reloadWarningPromise = getSessionReloadWarning(sessionId) + startSessionReloadWarning(sessionId, requestId, set) try { const response = await agentsApi.list(cwd) - const reloadWarning = await reloadWarningPromise if (requestId !== latestFetchRequestId) return set({ ...response, selectedAgent: null, selectedAgentReturnTab: 'agents', isMutating: false, - mutationWarning: reloadWarning, }) } catch (refreshError) { - const reloadWarning = await reloadWarningPromise if (requestId === latestFetchRequestId) { set((state) => ({ activeAgents: removeMutationAgent( @@ -274,7 +265,7 @@ export const useAgentStore = create((set, get) => ({ refreshError, 'Failed to refresh agents after deleting the agent', ), - reloadWarning, + state.mutationWarning, ), })) } @@ -360,6 +351,19 @@ function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback } +function startSessionReloadWarning( + sessionId: string | undefined, + requestId: number, + setState: typeof useAgentStore.setState, +) { + void getSessionReloadWarning(sessionId).then((reloadWarning) => { + if (requestId !== latestFetchRequestId) return + setState((state) => ({ + mutationWarning: combineWarnings(state.mutationWarning, reloadWarning), + })) + }) +} + async function getSessionReloadWarning( sessionId?: string, ): Promise { diff --git a/desktop/src/stores/cliTaskStore.test.ts b/desktop/src/stores/cliTaskStore.test.ts index 88ddcef5..89057c1c 100644 --- a/desktop/src/stores/cliTaskStore.test.ts +++ b/desktop/src/stores/cliTaskStore.test.ts @@ -156,6 +156,24 @@ describe('cliTaskStore', () => { ]) }) + it('coalesces overlapping task polls for the same session', async () => { + let resolvePoll: ((value: { tasks: CLITask[] }) => void) | null = null + vi.mocked(cliTasksApi.getTasksForList).mockImplementation( + () => new Promise((resolve) => { + resolvePoll = resolve + }), + ) + + const first = useCLITaskStore.getState().fetchSessionTasks('session-1') + const second = useCLITaskStore.getState().fetchSessionTasks('session-1') + + expect(cliTasksApi.getTasksForList).toHaveBeenCalledTimes(1) + resolvePoll!({ tasks: [makeTask('session-1')] }) + await Promise.all([first, second]) + + expect(useCLITaskStore.getState().tasks).toHaveLength(1) + }) + it('ignores an older task response that finishes after a newer refresh', async () => { let resolveOlder: ((value: { tasks: CLITask[] }) => void) | null = null let resolveNewer: ((value: { tasks: CLITask[] }) => void) | null = null diff --git a/desktop/src/stores/cliTaskStore.ts b/desktop/src/stores/cliTaskStore.ts index c23e3336..b501eb74 100644 --- a/desktop/src/stores/cliTaskStore.ts +++ b/desktop/src/stores/cliTaskStore.ts @@ -42,6 +42,7 @@ type CLITaskStore = { let taskRequestSequence = 0 let taskRequestGeneration = 0 const latestAppliedTaskRequestBySession = new Map() +const activeTaskPollBySession = new Map>() type TaskRequest = { requestId: number @@ -130,22 +131,36 @@ export const useCLITaskStore = create((set, get) => ({ }) } - const request = beginTaskRequest() - try { - const { tasks } = await cliTasksApi.getTasksForList(sessionId) - if ( - canApplyTaskResponse(sessionId, request) - && get().sessionId === sessionId - && !get().resetting - ) { - markTaskResponseApplied(sessionId, request) - set((state) => ({ - tasks, - ...resolveDismissState(tasks, state.dismissedCompletionKey), - })) + const activePoll = activeTaskPollBySession.get(sessionId) + if (activePoll) return activePoll + + const poll = (async () => { + const request = beginTaskRequest() + try { + const { tasks } = await cliTasksApi.getTasksForList(sessionId) + if ( + canApplyTaskResponse(sessionId, request) + && get().sessionId === sessionId + && !get().resetting + ) { + markTaskResponseApplied(sessionId, request) + set((state) => ({ + tasks, + ...resolveDismissState(tasks, state.dismissedCompletionKey), + })) + } + } catch { + // Preserve the last known task state across transient polling failures. + } + })() + + activeTaskPollBySession.set(sessionId, poll) + try { + await poll + } finally { + if (activeTaskPollBySession.get(sessionId) === poll) { + activeTaskPollBySession.delete(sessionId) } - } catch { - // Preserve the last known task state across transient polling failures. } }, diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 70ff0b29..adb6fd86 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -1699,6 +1699,46 @@ html[data-window-kind='pet'] #root { will-change: transform; } +.pet-mascot-wrap { + position: relative; + order: 2; + display: grid; + flex: 0 0 auto; + place-items: center; + pointer-events: none; +} + +.pet-task-badge { + position: absolute; + z-index: 12; + top: 8px; + right: 2px; + display: grid; + min-width: 24px; + height: 24px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 999px; + padding: 0 6px; + background: rgba(14, 58, 91, 0.94); + box-shadow: 0 5px 14px rgba(13, 31, 48, 0.26); + color: white; + cursor: pointer; + font: 650 12px/1 var(--font-body); + pointer-events: auto; + -webkit-app-region: no-drag; +} + +.pet-task-badge:hover { + background: rgba(20, 76, 116, 0.98); + transform: translateY(-1px); +} + +.pet-task-badge:focus-visible { + outline: 2px solid rgba(95, 179, 255, 0.78); + outline-offset: 2px; +} + .pet-mascot-button[data-dragging='true'] { cursor: grabbing; transform: scale(0.96); @@ -1787,14 +1827,18 @@ html[data-window-kind='pet'] #root { .pet-activity-card { position: relative; - z-index: 5; + z-index: 15; box-sizing: border-box; - width: 100%; + order: 1; + width: calc(100% - 32px); + max-width: 360px; flex: 0 0 auto; - overflow: hidden; + overflow: visible; + /* Keep the card, collapse control, and visible mascot as one compact stack. */ + margin-bottom: 12px; border: 1px solid rgba(255, 255, 255, 0.76); border-radius: 24px; - padding: 6px 50px 6px 7px; + padding: 6px 7px; background: rgba(255, 255, 255, 0.8); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68), @@ -1807,7 +1851,7 @@ html[data-window-kind='pet'] #root { .pet-activity-card[data-expanded='true'] { border-radius: 22px; - padding-right: 48px; + padding-right: 7px; } html[data-theme='dark'] .pet-activity-card { @@ -1860,7 +1904,7 @@ html[data-theme='dark'] .pet-session-list > [role='listitem'] + [role='listitem' width: 100%; min-height: 52px; align-items: center; - gap: 10px; + gap: 12px; border: 0; border-radius: 17px; padding: 7px 10px; @@ -1953,41 +1997,44 @@ html[data-theme='dark'] .pet-session-status { .pet-panel-toggle { position: absolute; - top: 50%; - right: 8px; + z-index: 13; + right: 50%; + bottom: -31px; display: grid; - width: 38px; - height: 38px; + width: 25px; + height: 25px; place-items: center; - border: 0; border-radius: 999px; - background: transparent; - color: rgba(66, 62, 74, 0.62); + border: 1px solid rgba(255, 255, 255, 0.7); + background: rgba(52, 55, 61, 0.72); + color: rgba(255, 255, 255, 0.88); cursor: pointer; - transform: translateY(-50%); + transform: translateX(50%); transition: background 140ms ease, color 140ms ease, transform 140ms ease; -webkit-app-region: no-drag; } .pet-activity-card[data-expanded='true'] .pet-panel-toggle { - top: 8px; - transform: none; + top: auto; + transform: translateX(50%); } .pet-panel-toggle:hover { - background: rgba(77, 71, 89, 0.09); - color: rgba(38, 35, 44, 0.9); + background: rgba(43, 47, 54, 0.88); + color: white; } .pet-panel-toggle:active { - transform: translateY(-50%) scale(0.94); + transform: translateX(50%) scale(0.94); } .pet-activity-card[data-expanded='true'] .pet-panel-toggle:active { - transform: scale(0.94); + transform: translateX(50%) scale(0.94); } html[data-theme='dark'] .pet-panel-toggle { + border-color: rgba(255, 255, 255, 0.18); + background: rgba(36, 39, 45, 0.82); color: rgba(239, 235, 246, 0.62); } diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index df821ad2..2a37f051 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -156,4 +156,12 @@ describe('desktop theme tokens', () => { expect(css).toContain('left: -9999em;') expect(css).toContain('overflow: hidden;') }) + + it('keeps the pet task card controls above the mascot hit target', () => { + const mascotCss = getCssBetween('.pet-mascot-button {', '.pet-mascot-wrap {') + const cardCss = getCssBetween('.pet-activity-card {', '.pet-activity-card[data-expanded=') + + expect(mascotCss).toContain('z-index: 10;') + expect(cardCss).toContain('z-index: 15;') + }) }) diff --git a/src/server/__tests__/agents-api.test.ts b/src/server/__tests__/agents-api.test.ts index c6521565..b33e0458 100644 --- a/src/server/__tests__/agents-api.test.ts +++ b/src/server/__tests__/agents-api.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path' import type { AppState } from '../../state/AppStateStore.js' import { handleAgentsApi } from '../api/agents.js' import { clearAgentDefinitionsCache } from '../../tools/AgentTool/loadAgentsDir.js' +import { findGitRoot } from '../../utils/git.js' import { refreshActivePlugins } from '../../utils/plugins/refresh.js' import { AgentService } from '../services/agentService.js' import { conversationService } from '../services/conversationService.js' @@ -460,6 +461,47 @@ describe('Agents API Markdown CRUD', () => { ).toBe(false) }) + it('creates a project agent in a nested repository initialized after its root was cached', async () => { + const nestedProjectRoot = path.join(projectRoot, 'manual-workspace') + await fs.mkdir(nestedProjectRoot, { recursive: true }) + + findGitRoot.cache.delete(nestedProjectRoot) + expect(findGitRoot(nestedProjectRoot)).toBe(projectRoot) + await fs.mkdir(path.join(nestedProjectRoot, '.git')) + + try { + const creation = await api('POST', '/api/agents', { + scope: 'project', + cwd: nestedProjectRoot, + name: 'nested-repository-reviewer', + description: 'Reviews the independently initialized nested repository', + systemPrompt: 'Keep project agent changes inside this repository.', + }) + + const nestedAgentFile = path.join( + nestedProjectRoot, + '.claude', + 'agents', + 'nested-repository-reviewer.md', + ) + expect(creation.status).toBe(201) + expect(creation.data.agent.target).toBe(await fs.realpath(nestedAgentFile)) + expect(await fileExists(nestedAgentFile)).toBe(true) + expect( + await fileExists( + path.join( + projectRoot, + '.claude', + 'agents', + 'nested-repository-reviewer.md', + ), + ), + ).toBe(false) + } finally { + findGitRoot.cache.delete(nestedProjectRoot) + } + }) + it('updates and deletes the exact nested project file when its filename differs from its agent name', async () => { const nestedProjectDir = path.join( projectRoot, diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 2446ef1d..fa3af240 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -310,36 +310,39 @@ describe('ConversationService', () => { expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).not.toContain('myself_code') }) - test('buildChildEnv inherits exported terminal shell variables for desktop CLI sessions', async () => { - const shellPath = path.join(tmpDir, 'zsh') - const nodeBin = path.join(tmpDir, 'node-bin') - const nvmDir = path.join(tmpDir, '.nvm') - await fs.mkdir(nodeBin, { recursive: true }) - await fs.mkdir(nvmDir, { recursive: true }) - await writeFakeZsh(shellPath) - await fs.writeFile( - path.join(tmpDir, '.zshrc'), - [ - `export NVM_DIR="${nvmDir}"`, - `export PATH="${nodeBin}:$PATH"`, - '', - ].join('\n'), - ) + test.skipIf(process.platform === 'win32')( + 'buildChildEnv inherits exported terminal shell variables for desktop CLI sessions', + async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + const nvmDir = path.join(tmpDir, '.nvm') + await fs.mkdir(nodeBin, { recursive: true }) + await fs.mkdir(nvmDir, { recursive: true }) + await writeFakeZsh(shellPath) + await fs.writeFile( + path.join(tmpDir, '.zshrc'), + [ + `export NVM_DIR="${nvmDir}"`, + `export PATH="${nodeBin}:$PATH"`, + '', + ].join('\n'), + ) - delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV - process.env.HOME = tmpDir - process.env.SHELL = shellPath - process.env.PATH = '/usr/bin:/bin' - delete process.env.ZDOTDIR - resetTerminalShellEnvironmentCacheForTests() + delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + delete process.env.ZDOTDIR + resetTerminalShellEnvironmentCacheForTests() - const service = new ConversationService() as any - const env = (await service.buildChildEnv(tmpDir)) as Record + const service = new ConversationService() as any + const env = (await service.buildChildEnv(tmpDir)) as Record - expect(env.NVM_DIR).toBe(nvmDir) - expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin) - expect(env.PATH.split(path.delimiter)).toContain('/usr/bin') - }) + expect(env.NVM_DIR).toBe(nvmDir) + expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin) + expect(env.PATH.split(path.delimiter)).toContain('/usr/bin') + }, + ) test('strips inherited provider env when desktop provider config exists', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 035fa185..6a85bb7f 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -138,6 +138,86 @@ describe('ConversationService', () => { await expect(request).resolves.toEqual({ ok: true }) }) + it('should remove a pending control callback when the HTTP request is aborted', async () => { + const svc = new ConversationService() + const sid = crypto.randomUUID() + const sent: unknown[] = [] + const session: any = { + proc: { kill() {}, exited: Promise.resolve(0) }, + outputCallbacks: [], + workDir: process.cwd(), + permissionMode: 'default', + sdkToken: 'token', + sdkSocket: { + send(data: string) { + sent.push(JSON.parse(data)) + }, + }, + pendingOutbound: [], + startupPending: false, + startupExitCode: null, + stdoutLines: [], + stderrLines: [], + outputDrain: Promise.resolve(), + sdkMessages: [], + initMessage: null, + pendingPermissionRequests: new Map(), + } + ;(svc as any).sessions.set(sid, session) + const controller = new AbortController() + + const request = svc.requestControl( + sid, + { subtype: 'get_context_usage' }, + 10_000, + controller.signal, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(sent).toHaveLength(1) + expect(session.outputCallbacks).toHaveLength(1) + + controller.abort(new Error('HTTP client disconnected')) + + await expect(request).rejects.toThrow('HTTP client disconnected') + expect(session.outputCallbacks).toHaveLength(0) + }) + + it('should remove the abort listener when a control request times out', async () => { + const svc = new ConversationService() + const sid = crypto.randomUUID() + const controller = new AbortController() + const removeAbortListener = spyOn(controller.signal, 'removeEventListener') + const session: any = { + proc: { kill() {}, exited: Promise.resolve(0) }, + outputCallbacks: [], + workDir: process.cwd(), + permissionMode: 'default', + sdkToken: 'token', + sdkSocket: { send() {} }, + pendingOutbound: [], + startupPending: false, + startupExitCode: null, + stdoutLines: [], + stderrLines: [], + outputDrain: Promise.resolve(), + sdkMessages: [], + initMessage: null, + pendingPermissionRequests: new Map(), + } + ;(svc as any).sessions.set(sid, session) + + await expect(svc.requestControl( + sid, + { subtype: 'get_context_usage' }, + 20, + controller.signal, + )).rejects.toThrow('Timed out waiting for get_context_usage response') + + expect(session.outputCallbacks).toHaveLength(0) + expect(removeAbortListener).toHaveBeenCalledWith('abort', expect.any(Function)) + }) + it('should ignore a stale SDK disconnect after a replacement socket attaches', () => { const svc = new ConversationService() const sessionId = crypto.randomUUID() @@ -413,7 +493,7 @@ describe('ConversationService', () => { const svc = new ConversationService() const sent: Array<{ request_id: string }> = [] const sessionId = 'session-permission-rejected' - ;(svc as any).sessions.set(sessionId, { + const session = { proc: null, outputCallbacks: [], workDir: process.cwd(), @@ -428,7 +508,8 @@ describe('ConversationService', () => { stderrLines: [], sdkMessages: [], pendingPermissionRequests: new Map(), - }) + } + ;(svc as any).sessions.set(sessionId, session) const change = svc.setPermissionMode(sessionId, 'auto') await new Promise((resolve) => setTimeout(resolve, 0)) @@ -443,6 +524,8 @@ describe('ConversationService', () => { await expect(change).rejects.toThrow('auto mode unavailable') expect(svc.getSessionPermissionMode(sessionId)).toBe('default') + expect(session.outputCallbacks).toHaveLength(0) + expect((svc as any).pendingPermissionModeChanges.size).toBe(0) }) it('should time out without recording a mode when control succeeds without CLI confirmation', async () => { @@ -1779,6 +1862,8 @@ describe('WebSocket Chat Integration', () => { afterAll(async () => { server?.stop(true) + const { stopServerRuntimeForShutdown } = await import('../index.js') + await stopServerRuntimeForShutdown() if (tmpDir) { await rmWithRetry(tmpDir) } diff --git a/src/server/__tests__/desktop-ui-preferences.test.ts b/src/server/__tests__/desktop-ui-preferences.test.ts index 9c610282..8b2ce3f3 100644 --- a/src/server/__tests__/desktop-ui-preferences.test.ts +++ b/src/server/__tests__/desktop-ui-preferences.test.ts @@ -12,6 +12,7 @@ const DEFAULT_PET_PREFERENCES = { enabled: false, selectedPetId: 'dada-code', size: 144, + showTaskPanel: false, collapsed: false, motionEnabled: true, lastSessionId: null, @@ -65,7 +66,7 @@ describe('DesktopUiPreferencesService', () => { expect(result.exists).toBe(false) expect(result.preferences).toEqual({ - schemaVersion: 3, + schemaVersion: 4, profile: { displayName: 'cc-haha', subtitle: 'github.com/NanmiCoder/cc-haha', @@ -109,7 +110,7 @@ describe('DesktopUiPreferencesService', () => { expect(before.exists).toBe(true) expect(before.preferences).toEqual({ - schemaVersion: 3, + schemaVersion: 4, futureField: { keep: true }, profile: { displayName: 'cc-haha', @@ -127,7 +128,7 @@ describe('DesktopUiPreferencesService', () => { }, }) expect(after).toEqual({ - schemaVersion: 3, + schemaVersion: 4, futureField: { keep: true }, profile: { displayName: 'cc-haha', @@ -220,7 +221,7 @@ describe('DesktopUiPreferencesService', () => { }) expect(after).toMatchObject({ - schemaVersion: 3, + schemaVersion: 4, futureField: { keep: true }, profile: { displayName: 'Local Operator', @@ -253,13 +254,14 @@ describe('DesktopUiPreferencesService', () => { enabled: false, selectedPetId: 'huhu-plan', size: 144, + showTaskPanel: false, collapsed: false, motionEnabled: true, lastSessionId: 'session-before', }) await Promise.all([ - settingsRenderer.updatePetPreferences({ size: 176, motionEnabled: false }), + settingsRenderer.updatePetPreferences({ size: 176, showTaskPanel: true, motionEnabled: false }), petRenderer.updatePetPreferences({ collapsed: true, lastSessionId: 'session-after' }), ]) @@ -268,18 +270,19 @@ describe('DesktopUiPreferencesService', () => { enabled: false, selectedPetId: 'huhu-plan', size: 176, + showTaskPanel: true, collapsed: true, motionEnabled: false, lastSessionId: 'session-after', }) }) - test('patches an old-schema pet while preserving its other fields and unknown root fields', async () => { + test('migrates a schema-3 pet to a hidden task panel while preserving other and unknown fields', async () => { await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) await fs.writeFile( path.join(tmpDir, 'cc-haha', 'desktop-ui.json'), JSON.stringify({ - schemaVersion: 2, + schemaVersion: 3, futureField: { keep: true }, pet: { futurePetField: { keep: 'pet-too' }, @@ -297,13 +300,14 @@ describe('DesktopUiPreferencesService', () => { const after = await new DesktopUiPreferencesService().updatePetPreferences({ enabled: true }) expect(after).toMatchObject({ - schemaVersion: 3, + schemaVersion: 4, futureField: { keep: true }, pet: { futurePetField: { keep: 'pet-too' }, enabled: true, selectedPetId: 'custom:rocky-bot', size: 168, + showTaskPanel: false, collapsed: true, motionEnabled: false, lastSessionId: 'session-old', @@ -385,7 +389,7 @@ describe('DesktopUiPreferencesService', () => { }) expect(after).toEqual({ - schemaVersion: 3, + schemaVersion: 4, profile: { displayName: 'Claude Captain', subtitle: 'local.example/profile', @@ -456,7 +460,7 @@ describe('desktop UI preferences API', () => { expect(putBody).toEqual({ ok: true, preferences: { - schemaVersion: 3, + schemaVersion: 4, profile: { displayName: 'cc-haha', subtitle: 'github.com/NanmiCoder/cc-haha', @@ -482,7 +486,7 @@ describe('desktop UI preferences API', () => { expect(getBody).toEqual({ exists: true, preferences: { - schemaVersion: 3, + schemaVersion: 4, profile: { displayName: 'cc-haha', subtitle: 'github.com/NanmiCoder/cc-haha', @@ -506,6 +510,7 @@ describe('desktop UI preferences API', () => { enabled: true, selectedPetId: ' seedy ', size: 40, + showTaskPanel: true, collapsed: false, motionEnabled: true, lastSessionId: 'session-42', @@ -517,11 +522,12 @@ describe('desktop UI preferences API', () => { await expect(putRes.json()).resolves.toMatchObject({ ok: true, preferences: { - schemaVersion: 3, + schemaVersion: 4, pet: { enabled: true, selectedPetId: 'seedy', size: 96, + showTaskPanel: true, collapsed: false, motionEnabled: true, lastSessionId: 'session-42', diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index adb0d593..246fee5b 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -75,6 +75,18 @@ async function startTestServer() { wsUrl = `ws://127.0.0.1:${server.port}` } +async function stopTestServer() { + server?.stop(true) + const { stopServerRuntimeForShutdown } = await import('../../index.js') + await stopServerRuntimeForShutdown() + await fs.rm(tmpDir, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 5 : 0, + retryDelay: 100, + }) +} + async function api(method: string, urlPath: string, body?: unknown) { const res = await fetch(`${baseUrl}${urlPath}`, { method, @@ -87,10 +99,7 @@ async function api(method: string, urlPath: string, body?: unknown) { describe('Business Flow: Scheduled Tasks', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) // ========================================================================== // 定时任务完整生命周期 @@ -207,10 +216,7 @@ describe('Business Flow: Scheduled Tasks', () => { describe('Business Flow: Permission Modes', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) const VALID_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', 'auto'] @@ -253,10 +259,7 @@ describe('Business Flow: Permission Modes', () => { describe('Business Flow: Agent Management', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should start with shared active/all agent payload', async () => { const { data } = await api('GET', '/api/agents') @@ -367,10 +370,7 @@ describe('Business Flow: Agent Management', () => { describe('Business Flow: Models & Effort', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should return available fallback models', async () => { const { data } = await api('GET', '/api/models') @@ -454,10 +454,7 @@ describe('Business Flow: Models & Effort', () => { describe('Business Flow: Sessions & CLI Interop', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) let sessionId: string @@ -580,10 +577,7 @@ describe('Business Flow: Search', () => { await fs.writeFile(path.join(testDir, 'utils.ts'), 'export function helper() { return 42 }\n') await fs.writeFile(path.join(testDir, 'config.json'), '{"port": 3456}\n') }) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should find matches in workspace files', async () => { const { status, data } = await api('POST', '/api/search', { @@ -623,10 +617,7 @@ describe('Business Flow: Search', () => { describe('Business Flow: WebSocket Chat', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should establish WebSocket connection and receive connected event', async () => { const messages: any[] = [] @@ -762,10 +753,7 @@ describe('Business Flow: WebSocket Chat', () => { describe('Business Flow: Settings Persistence', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should write and read complex settings', async () => { const settings = { @@ -822,10 +810,7 @@ describe('Business Flow: Settings Persistence', () => { describe('Business Flow: Status & Diagnostics', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should return health with uptime', async () => { const { data } = await api('GET', '/api/status') @@ -862,10 +847,7 @@ describe('Business Flow: Status & Diagnostics', () => { describe('Business Flow: Error Handling', () => { beforeAll(startTestServer) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) it('should return 404 for unknown API resource', async () => { const { status, data } = await api('GET', '/api/unknown') diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index 74d910d7..d88c7a90 100644 --- a/src/server/__tests__/e2e/full-flow.test.ts +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -74,6 +74,18 @@ async function startTestServer() { baseUrl = `http://127.0.0.1:${server.port}` } +async function stopTestServer() { + server?.stop(true) + const { stopServerRuntimeForShutdown } = await import('../../index.js') + await stopServerRuntimeForShutdown() + await fs.rm(tmpDir, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 5 : 0, + retryDelay: 100, + }) +} + async function api(method: string, path: string, body?: unknown): Promise<{ status: number; data: any }> { const res = await fetch(`${baseUrl}${path}`, { method, @@ -89,10 +101,7 @@ describe('E2E: Full Flow', () => { await startTestServer() }) - afterAll(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + afterAll(stopTestServer) // ============================================= // 1. Health & Status @@ -100,19 +109,21 @@ describe('E2E: Full Flow', () => { it('should return healthy status', async () => { const res = await fetch(`${baseUrl}/health`) + expect(res.headers.get('access-control-allow-origin')).toBe('*') const data = await res.json() expect(data.status).toBe('ok') }) it('should return server status', async () => { - const { data } = await api('GET', '/api/status') + const response = await fetch(`${baseUrl}/api/status`) + const data = await response.json() expect(data.status).toBe('ok') expect(data.version).toBeDefined() }) it('should return diagnostics', async () => { const { data } = await api('GET', '/api/status/diagnostics') - expect(data.platform).toBe('darwin') + expect(data.platform).toBe(process.platform) expect(data.configDir).toBe(tmpDir) }) diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 81357279..6c16b6b8 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' -import { startServer } from '../index.js' +import { startServer, stopServerRuntimeForShutdown } from '../index.js' import { clearFilesystemAccessRootsForTests, registerFilesystemAccessRoot, @@ -26,6 +26,7 @@ let originalLocalAccessToken: string | undefined let originalPetAccessToken: string | undefined let originalServerPort = 3456 const PHONE_ORIGIN = 'https://phone.example' +const SERVER_STOP_WAIT_MS = 500 async function waitForServer(url: string): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { @@ -78,9 +79,24 @@ async function startRemoteServer(options: { authRequired?: boolean } = {}): Prom await waitForServer(`${baseUrl}/health`) } -async function restartRemoteServer(options: { authRequired?: boolean } = {}): Promise { - server?.stop(true) +async function stopRemoteServer(): Promise { + const runningServer = server server = undefined + await stopServerRuntimeForShutdown({ waitForCli: false }) + if (!runningServer) return + + let timeout: ReturnType | undefined + await Promise.race([ + runningServer.stop(true), + new Promise((resolve) => { + timeout = setTimeout(resolve, SERVER_STOP_WAIT_MS) + }), + ]) + if (timeout) clearTimeout(timeout) +} + +async function restartRemoteServer(options: { authRequired?: boolean } = {}): Promise { + await stopRemoteServer() await startRemoteServer(options) } @@ -133,15 +149,24 @@ async function enableH5Access(options: { function expectWebSocketOpen(url: string): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(url) + let opened = false const timeout = setTimeout(() => { ws.close() reject(new Error(`Timed out opening websocket: ${url}`)) }, 5000) ws.addEventListener('open', () => { - clearTimeout(timeout) + opened = true ws.close() - resolve() + }) + + ws.addEventListener('close', () => { + clearTimeout(timeout) + if (opened) { + resolve() + } else { + reject(new Error(`WebSocket closed before upgrade completed: ${url}`)) + } }) ws.addEventListener('error', () => { @@ -215,8 +240,7 @@ beforeEach(async () => { }) afterEach(async () => { - server?.stop(true) - server = undefined + await stopRemoteServer() clearFilesystemAccessRootsForTests() ProviderService.setServerPort(originalServerPort) @@ -236,7 +260,12 @@ afterEach(async () => { if (originalPetAccessToken === undefined) delete process.env.CC_HAHA_PET_ACCESS_TOKEN else process.env.CC_HAHA_PET_ACCESS_TOKEN = originalPetAccessToken - await fs.rm(tmpDir, { recursive: true, force: true }) + await fs.rm(tmpDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 50, + }) }) describe('remote H5 auth and CORS integration', () => { @@ -382,6 +411,7 @@ describe('remote H5 auth and CORS integration', () => { enabled: false, selectedPetId: 'dada-code', size: 144, + showTaskPanel: false, collapsed: true, motionEnabled: true, lastSessionId: null, diff --git a/src/server/__tests__/haha-grok-oauth-service.test.ts b/src/server/__tests__/haha-grok-oauth-service.test.ts index 076cc7a2..6e03f379 100644 --- a/src/server/__tests__/haha-grok-oauth-service.test.ts +++ b/src/server/__tests__/haha-grok-oauth-service.test.ts @@ -77,7 +77,9 @@ describe('HahaGrokOAuthService', () => { accessToken: 'grok-access', refreshToken: 'grok-refresh', }) - expect((await fs.stat(getHahaGrokOAuthFilePath())).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await fs.stat(getHahaGrokOAuthFilePath())).mode & 0o777).toBe(0o600) + } }) test('refreshes an expiring token and preserves an unrotated refresh token', async () => { diff --git a/src/server/__tests__/haha-openai-oauth-api.test.ts b/src/server/__tests__/haha-openai-oauth-api.test.ts index d1149cc1..c06d7c93 100644 --- a/src/server/__tests__/haha-openai-oauth-api.test.ts +++ b/src/server/__tests__/haha-openai-oauth-api.test.ts @@ -9,7 +9,7 @@ import * as os from 'os' import { createServer } from 'net' import { handleHahaOpenAIOAuthApi } from '../api/haha-openai-oauth.js' import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js' -import { startServer } from '../index.js' +import { startServer, stopServerRuntimeForShutdown } from '../index.js' import { ProviderService } from '../services/providerService.js' let tmpDir: string @@ -200,7 +200,8 @@ describe('GET /auth/callback', () => { expect(html).toContain('OpenAI Login Failed') expect(html).toContain('Missing code or state parameter') } finally { - server.stop(true) + await server.stop(true) + await stopServerRuntimeForShutdown({ waitForCli: false }) ProviderService.setServerPort(originalServerPort) } }) diff --git a/src/server/__tests__/mcp.test.ts b/src/server/__tests__/mcp.test.ts index be769dfa..40a3785a 100644 --- a/src/server/__tests__/mcp.test.ts +++ b/src/server/__tests__/mcp.test.ts @@ -7,6 +7,7 @@ import * as mcpClient from '../../services/mcp/client.js' import * as mcpConfig from '../../services/mcp/config.js' import { _setGlobalConfigCacheForTesting, getProjectPathForConfig } from '../../utils/config.js' import { getGlobalClaudeFile } from '../../utils/env.js' +import { normalizePathForConfigKey } from '../../utils/path.js' import * as mcpHostPreflight from '../services/mcpHostPreflight.js' import { handleMcpApi } from '../api/mcp.js' import { conversationService } from '../services/conversationService.js' @@ -110,6 +111,8 @@ describe('MCP API', () => { const previousOriginalCwd = getOriginalCwd() const projectA = path.join(tmpDir, 'project-a') const projectB = path.join(tmpDir, 'project-b') + const projectAKey = normalizePathForConfigKey(projectA) + const projectBKey = normalizePathForConfigKey(projectB) await fs.mkdir(projectA, { recursive: true }) await fs.mkdir(projectB, { recursive: true }) @@ -144,13 +147,13 @@ describe('MCP API', () => { await fs.readFile(path.join(tmpDir, '.claude.json'), 'utf8'), ) - expect(rawConfig.projects?.[projectA]?.mcpServers?.['scoped-server']).toBeUndefined() - expect(rawConfig.projects?.[projectA]?.disabledMcpServers ?? []).not.toContain('scoped-server') - expect(rawConfig.projects?.[projectB]?.mcpServers?.['scoped-server']).toMatchObject({ + expect(rawConfig.projects?.[projectAKey]?.mcpServers?.['scoped-server']).toBeUndefined() + expect(rawConfig.projects?.[projectAKey]?.disabledMcpServers ?? []).not.toContain('scoped-server') + expect(rawConfig.projects?.[projectBKey]?.mcpServers?.['scoped-server']).toMatchObject({ type: 'stdio', command: 'node', }) - expect(rawConfig.projects?.[projectB]?.disabledMcpServers).toContain('scoped-server') + expect(rawConfig.projects?.[projectBKey]?.disabledMcpServers).toContain('scoped-server') } finally { if (previousNodeEnv === undefined) { delete process.env.NODE_ENV @@ -265,7 +268,7 @@ describe('MCP API', () => { expect(projectPathsRes.status).toBe(200) const body = await projectPathsRes.json() - expect(body.projectPaths).toEqual([projectB]) + expect(body.projectPaths).toEqual([normalizePathForConfigKey(projectB)]) } finally { if (previousNodeEnv === undefined) { delete process.env.NODE_ENV diff --git a/src/server/__tests__/searchService.sessions.test.ts b/src/server/__tests__/searchService.sessions.test.ts index 971d34ce..84c1e9bc 100644 --- a/src/server/__tests__/searchService.sessions.test.ts +++ b/src/server/__tests__/searchService.sessions.test.ts @@ -636,7 +636,8 @@ describe('SearchService.searchSessions', () => { expect(directoryAfter.mtimeMs).toBe(directoryBefore.mtimeMs) expect(directoryAfter.ctimeMs).toBe(directoryBefore.ctimeMs) - expect((await fs.stat(transcriptPath)).mtimeMs).toBe(sourceBefore.mtimeMs) + const restoredMtimeMs = (await fs.stat(transcriptPath)).mtimeMs + expect(Math.abs(restoredMtimeMs - sourceBefore.mtimeMs)).toBeLessThan(1) expect(filterCalls).toBe(2) expect(rgCalls).toBe(2) expect(results.map(result => result.sessionId)).toEqual(['changed-session']) diff --git a/src/server/__tests__/server-options.test.ts b/src/server/__tests__/server-options.test.ts new file mode 100644 index 00000000..4fc2ad86 --- /dev/null +++ b/src/server/__tests__/server-options.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'bun:test' +import { HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS } from '../index.js' + +describe('server connection lifetime', () => { + it('lets HTTP clients own pooled connection lifetime', () => { + expect(HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS).toBe(0) + }) +}) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index c1352d0f..dab6b0e4 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -151,10 +151,7 @@ function git(cwd: string, ...args: string[]): string { } async function createWorkspaceApiGitRepo(baseDir: string): Promise { - const workDir = path.join( - baseDir, - `workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ) + const workDir = await fs.mkdtemp(path.join(baseDir, 'w-')) const javaControllerDir = path.join( workDir, diff --git a/src/server/__tests__/tasks.test.ts b/src/server/__tests__/tasks.test.ts index 5e07d9cc..ef8459ae 100644 --- a/src/server/__tests__/tasks.test.ts +++ b/src/server/__tests__/tasks.test.ts @@ -18,6 +18,15 @@ const taskFixture = (overrides: Record) => ({ ...overrides, }) +async function rmWithRetry(targetPath: string): Promise { + await fs.rm(targetPath, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 5 : 0, + retryDelay: 100, + }) +} + // ============================================================================ // TaskService unit tests // ============================================================================ @@ -135,8 +144,10 @@ describe('Tasks API', () => { }) afterEach(async () => { - server?.stop() - await fs.rm(tmpDir, { recursive: true, force: true }) + server?.stop(true) + const { stopServerRuntimeForShutdown } = await import('../../server/index.js') + await stopServerRuntimeForShutdown() + await rmWithRetry(tmpDir) delete process.env.CLAUDE_CONFIG_DIR }) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index bfa0e077..df854932 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -195,7 +195,7 @@ export async function handleSessionsApi( { status: 405 } ) } - return await getSessionInspection(sessionId, url) + return await getSessionInspection(req, sessionId, url) } if (subResource === 'workspace') { @@ -632,7 +632,7 @@ async function getSessionSlashCommands(sessionId: string): Promise { return Response.json({ commands: slashCommands }) } -async function getSessionInspection(sessionId: string, url: URL): Promise { +async function getSessionInspection(req: Request, sessionId: string, url: URL): Promise { const includeContext = url.searchParams.get('includeContext') !== '0' const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1' let transcriptSnapshot: Awaited> | undefined @@ -714,8 +714,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise diff --git a/src/server/index.ts b/src/server/index.ts index 34118752..7985b707 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -42,6 +42,7 @@ import { isPetSessionInProjection, PET_SESSION_LIMIT, } from './petAccessPolicy.js' +import { settleResponseOnRequestAbort } from './requestLifecycle.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -73,6 +74,7 @@ const PORT = SERVER_OPTIONS.port const HOST = SERVER_OPTIONS.host const SEARCH_INDEX_PRIMARY_WAIT_MS = 30_000 const SEARCH_INDEX_PRIMARY_POLL_MS = 50 +export const HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS = 0 type BackgroundIndexStartupOptions = { startPrimary?: () => Promise @@ -211,6 +213,10 @@ export function startServer(port = PORT, host = HOST) { ? '127.0.0.1' : host + // Chromium can keep HTTP/1.1 sockets pooled longer than Bun's request idle + // timeout. On Windows, reusing a socket after Bun timed it out can leave the + // request waiting for response headers until the renderer's 120s deadline. + // Let the client own the lifetime of these local pooled connections instead. /** * Explicit deployment auth remains a stronger override than H5-scoped * request gating. @@ -226,11 +232,27 @@ export function startServer(port = PORT, host = HOST) { server = Bun.serve({ port, hostname: host, - idleTimeout: 60, + idleTimeout: HTTP_CONNECTION_IDLE_TIMEOUT_SECONDS, async fetch(req, server) { - await ensurePersistentStorageUpgraded() const url = new URL(req.url) + + // Startup probes must not wait on migrations, config reads, or auth. + // Electron deliberately uses this endpoint to decide when the sidecar + // is ready, so keep it independent of every other runtime subsystem. + if (url.pathname === '/health') { + return Response.json( + { status: 'ok', timestamp: new Date().toISOString() }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }, + }, + ) + } + + await ensurePersistentStorageUpgraded() const origin = req.headers.get('Origin') const clientAddress = server.requestIP(req)?.address ?? null const localTokenOverride = url.searchParams.get('localToken') ?? url.searchParams.get('token') @@ -479,7 +501,10 @@ export function startServer(port = PORT, host = HOST) { } try { - const response = await handleApiRequest(req, url) + const response = await settleResponseOnRequestAbort( + req, + handleApiRequest(req, url), + ) return withCors(response, cors) } catch (error) { void diagnosticsService.recordEvent({ @@ -531,18 +556,6 @@ export function startServer(port = PORT, host = HOST) { } } - // Health check - if (url.pathname === '/health') { - if (cors.rejected) { - return corsRejectedResponse(cors) - } - - return Response.json( - { status: 'ok', timestamp: new Date().toISOString() }, - { headers: cors.headers }, - ) - } - // Static H5 shell/assets are non-secret bootstrap content and must load // before the browser can read the QR token; API/proxy/ws stay protected above. const staticResponse = await handleStaticH5Request(req, url) diff --git a/src/server/requestLifecycle.test.ts b/src/server/requestLifecycle.test.ts new file mode 100644 index 00000000..6b239868 --- /dev/null +++ b/src/server/requestLifecycle.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'bun:test' +import { settleResponseOnRequestAbort } from './requestLifecycle' + +describe('settleResponseOnRequestAbort', () => { + it('releases the server request when the client disconnects', async () => { + const controller = new AbortController() + const request = new Request('http://127.0.0.1/api/status', { + signal: controller.signal, + }) + const pending = new Promise(() => undefined) + + const responsePromise = settleResponseOnRequestAbort(request, pending) + controller.abort() + + const response = await responsePromise + expect(response.status).toBe(499) + }) + + it('returns completed responses and removes its abort listener', async () => { + const controller = new AbortController() + const request = new Request('http://127.0.0.1/api/status', { + signal: controller.signal, + }) + let added = 0 + let removed = 0 + const originalAdd = request.signal.addEventListener.bind(request.signal) + const originalRemove = request.signal.removeEventListener.bind(request.signal) + request.signal.addEventListener = ((...args: Parameters) => { + added += 1 + return originalAdd(...args) + }) as AbortSignal['addEventListener'] + request.signal.removeEventListener = ((...args: Parameters) => { + removed += 1 + return originalRemove(...args) + }) as AbortSignal['removeEventListener'] + + const response = await settleResponseOnRequestAbort( + request, + Promise.resolve(Response.json({ ok: true })), + ) + + expect(response.status).toBe(200) + expect(added).toBe(1) + expect(removed).toBe(1) + }) +}) diff --git a/src/server/requestLifecycle.ts b/src/server/requestLifecycle.ts new file mode 100644 index 00000000..09590274 --- /dev/null +++ b/src/server/requestLifecycle.ts @@ -0,0 +1,25 @@ +const CLIENT_CLOSED_REQUEST_STATUS = 499 + +function clientClosedResponse(): Response { + return new Response(null, { status: CLIENT_CLOSED_REQUEST_STATUS }) +} + +export async function settleResponseOnRequestAbort( + request: Request, + operation: Promise, +): Promise { + if (request.signal.aborted) return clientClosedResponse() + + let resolveAborted!: (response: Response) => void + const aborted = new Promise(resolve => { + resolveAborted = resolve + }) + const onAbort = () => resolveAborted(clientClosedResponse()) + request.signal.addEventListener('abort', onAbort, { once: true }) + + try { + return await Promise.race([operation, aborted]) + } finally { + request.signal.removeEventListener('abort', onAbort) + } +} diff --git a/src/server/services/agentService.ts b/src/server/services/agentService.ts index 8fe144ab..9ec05c28 100644 --- a/src/server/services/agentService.ts +++ b/src/server/services/agentService.ts @@ -281,6 +281,11 @@ export class AgentService { throw ApiError.badRequest('Agent project cwd must be an existing directory') } + // Agent CRUD must observe repository boundaries as they exist now. A + // long-running desktop session may have resolved this directory before a + // nested repository was initialized, leaving findGitRoot's LRU entry + // pointing at an ancestor repository. + findGitRoot.cache.delete(realCwd) const projectRoot = findGitRoot(realCwd) ?? realCwd const realProjectRoot = await fs.realpath(projectRoot) const worktreeAgentsCandidate = path.join( diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 0767824d..2adcc945 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -645,13 +645,21 @@ export class ConversationService { this.trackPendingPermissionModeChange(sessionId, mode, 1) let confirmationSettled = false - let confirmationTimeout: ReturnType - let handleOutput: (msg: any) => void + let confirmationTimeout: ReturnType | undefined + let handleOutput: ((msg: any) => void) | undefined + let rejectConfirmation: ((reason?: unknown) => void) | undefined const cleanupConfirmation = () => { - clearTimeout(confirmationTimeout) - this.removeOutputCallback(sessionId, handleOutput) + if (confirmationTimeout !== undefined) clearTimeout(confirmationTimeout) + if (handleOutput) this.removeOutputCallback(sessionId, handleOutput) + } + const cancelConfirmation = (reason: unknown) => { + if (confirmationSettled) return + confirmationSettled = true + cleanupConfirmation() + rejectConfirmation?.(reason) } const confirmation = new Promise((resolve, reject) => { + rejectConfirmation = reject handleOutput = (msg: any) => { if ( msg?.type !== 'system' || @@ -665,12 +673,6 @@ export class ConversationService { cleanupConfirmation() resolve() } - - confirmationTimeout = setTimeout(() => { - confirmationSettled = true - cleanupConfirmation() - reject(new Error(`Timed out waiting for permission mode confirmation: ${mode}`)) - }, timeoutMs) this.onOutput(sessionId, handleOutput) }) // requestControl can reject before the confirmation promise is awaited. @@ -678,15 +680,22 @@ export class ConversationService { void confirmation.catch(() => undefined) try { + const startedAt = Date.now() await this.requestControl(sessionId, { subtype: 'set_permission_mode', mode, }, timeoutMs) + if (!confirmationSettled) { + const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt)) + confirmationTimeout = setTimeout(() => { + cancelConfirmation(new Error(`Timed out waiting for permission mode confirmation: ${mode}`)) + }, remainingMs) + } await confirmation return this.sessions.has(sessionId) } catch (err) { - if (!confirmationSettled) cleanupConfirmation() + cancelConfirmation(err) throw err } finally { this.trackPendingPermissionModeChange(sessionId, mode, -1) @@ -736,10 +745,12 @@ export class ConversationService { private async waitForControlChannelReady( sessionId: string, timeoutMs: number, + signal?: AbortSignal, ): Promise { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { + if (signal?.aborted) throw controlRequestAbortReason(signal) const session = this.sessions.get(sessionId) if (!session) { throw new Error('CLI session is not running') @@ -747,7 +758,7 @@ export class ConversationService { if (this.isControlChannelReady(session)) { return } - await new Promise((resolve) => setTimeout(resolve, CONTROL_READY_POLL_MS)) + await waitForControlPoll(CONTROL_READY_POLL_MS, signal) } throw new Error('Timed out waiting for CLI control channel to become ready') @@ -757,27 +768,36 @@ export class ConversationService { sessionId: string, request: Record, timeoutMs = 10_000, + signal?: AbortSignal, ): Promise> { + if (signal?.aborted) { + return Promise.reject(controlRequestAbortReason(signal)) + } if (!this.sessions.has(sessionId)) { return Promise.reject(new Error('CLI session is not running')) } const startedAt = Date.now() - await this.waitForControlChannelReady(sessionId, timeoutMs) + await this.waitForControlChannelReady(sessionId, timeoutMs, signal) const responseTimeoutMs = Math.max(1, timeoutMs - (Date.now() - startedAt)) const requestId = crypto.randomUUID() return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeOutputCallback(sessionId, handleOutput) - reject(new Error(`Timed out waiting for ${String(request.subtype ?? 'control')} response`)) - }, responseTimeoutMs) + let settled = false + let timeout: ReturnType const finish = (fn: () => void) => { + if (settled) return + settled = true clearTimeout(timeout) + signal?.removeEventListener('abort', handleAbort) this.removeOutputCallback(sessionId, handleOutput) fn() } + const handleAbort = () => { + finish(() => reject(controlRequestAbortReason(signal!))) + } + const handleOutput = (msg: any) => { if ( msg?.type !== 'control_response' || @@ -798,7 +818,17 @@ export class ConversationService { )) } + timeout = setTimeout(() => { + finish(() => reject(new Error( + `Timed out waiting for ${String(request.subtype ?? 'control')} response`, + ))) + }, responseTimeoutMs) this.onOutput(sessionId, handleOutput) + signal?.addEventListener('abort', handleAbort, { once: true }) + if (signal?.aborted) { + handleAbort() + return + } const sent = this.sendSdkMessage(sessionId, { type: 'control_request', request_id: requestId, @@ -2165,6 +2195,29 @@ export class ConversationService { } } +function controlRequestAbortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason + return new DOMException('The operation was aborted', 'AbortError') +} + +function waitForControlPoll(timeoutMs: number, signal?: AbortSignal): Promise { + if (!signal) return new Promise((resolve) => setTimeout(resolve, timeoutMs)) + if (signal.aborted) return Promise.reject(controlRequestAbortReason(signal)) + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', handleAbort) + resolve() + }, timeoutMs) + const handleAbort = () => { + clearTimeout(timeout) + reject(controlRequestAbortReason(signal)) + } + signal.addEventListener('abort', handleAbort, { once: true }) + if (signal.aborted) handleAbort() + }) +} + function normalizeSessionPermissionUpdates( suggestions: unknown[] | undefined, toolName: string, diff --git a/src/server/services/desktopUiPreferencesService.ts b/src/server/services/desktopUiPreferencesService.ts index e4f98d94..7e392ddc 100644 --- a/src/server/services/desktopUiPreferencesService.ts +++ b/src/server/services/desktopUiPreferencesService.ts @@ -6,7 +6,7 @@ import { ApiError } from '../middleware/errorHandler.js' import { readRecoverableJsonFile } from './recoverableJsonFile.js' import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js' -const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 3 +const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 4 const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000 const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80 const MAX_PROFILE_SUBTITLE_LENGTH = 160 @@ -44,6 +44,7 @@ export type DesktopPetPreferences = { enabled: boolean selectedPetId: string size: number + showTaskPanel: boolean collapsed: boolean motionEnabled: boolean lastSessionId: string | null @@ -81,6 +82,7 @@ const DEFAULT_PET_PREFERENCES: DesktopPetPreferences = { enabled: false, selectedPetId: DEFAULT_PET_ID, size: DEFAULT_PET_SIZE, + showTaskPanel: false, collapsed: false, motionEnabled: true, lastSessionId: null, @@ -196,6 +198,9 @@ export function normalizeDesktopPetPreferences(value: unknown): DesktopPetPrefer enabled: typeof record.enabled === 'boolean' ? record.enabled : DEFAULT_PET_PREFERENCES.enabled, selectedPetId: normalizePetId(record.selectedPetId), size: normalizePetSize(record.size), + showTaskPanel: typeof record.showTaskPanel === 'boolean' + ? record.showTaskPanel + : DEFAULT_PET_PREFERENCES.showTaskPanel, collapsed: typeof record.collapsed === 'boolean' ? record.collapsed : DEFAULT_PET_PREFERENCES.collapsed, motionEnabled: typeof record.motionEnabled === 'boolean' ? record.motionEnabled diff --git a/src/server/services/localIndex/fileReader.test.ts b/src/server/services/localIndex/fileReader.test.ts index 10194017..044480b7 100644 --- a/src/server/services/localIndex/fileReader.test.ts +++ b/src/server/services/localIndex/fileReader.test.ts @@ -160,7 +160,7 @@ describe('readCompleteJsonlRange', () => { } return { size: payload.length, - mtimeMs: 1, + mtimeMs: race === 'replace' ? 2 : 1, dev: 2, ino: race === 'replace' ? 4 : 3, } diff --git a/src/server/services/localIndex/reconciliationWatcher.test.ts b/src/server/services/localIndex/reconciliationWatcher.test.ts index 226d6417..cc920b4b 100644 --- a/src/server/services/localIndex/reconciliationWatcher.test.ts +++ b/src/server/services/localIndex/reconciliationWatcher.test.ts @@ -220,12 +220,6 @@ describe('local index reconciliation watcher', () => { const scope = await createTempDir() const projectDir = join(scope, 'projects', '-repo') await mkdir(projectDir, { recursive: true }) - const entered = { resolve: () => {}, promise: Promise.resolve() } - let resolveEntered!: () => void - entered.promise = new Promise(resolve => { - resolveEntered = resolve - }) - entered.resolve = resolveEntered let releaseFirst!: () => void const firstRelease = new Promise(resolve => { releaseFirst = resolve @@ -240,20 +234,24 @@ describe('local index reconciliation watcher', () => { onBatch: async batch => { batches.push(batch) if (batches.length === 1) { - entered.resolve() await firstRelease } }, }) await watcher.start() - await entered.promise - watcher.queueTranscriptPath(join(projectDir, 'late.jsonl')) - const stopping = watcher.stop() - releaseFirst() - await stopping - await Bun.sleep(40) + try { + await waitFor(() => batches.length > 0) + watcher.queueTranscriptPath(join(projectDir, 'late.jsonl')) + const stopping = watcher.stop() + releaseFirst() + await stopping + await Bun.sleep(40) - expect(batches).toEqual([{ paths: [], fullSweep: true }]) + expect(batches).toEqual([{ paths: [], fullSweep: true }]) + } finally { + releaseFirst() + await watcher.stop() + } }) test('reports watch failure once per attempt and retries with bounded backoff', async () => { diff --git a/src/server/services/localIndex/scheduledRunReadModel.test.ts b/src/server/services/localIndex/scheduledRunReadModel.test.ts index 3289de55..ecce0173 100644 --- a/src/server/services/localIndex/scheduledRunReadModel.test.ts +++ b/src/server/services/localIndex/scheduledRunReadModel.test.ts @@ -17,6 +17,7 @@ import { CronScheduler } from '../cronScheduler.js' let tmpDir: string | undefined const originalConfigDir = process.env.CLAUDE_CONFIG_DIR const originalLocalIndexMode = process.env.CC_HAHA_LOCAL_INDEX +const exactFileTime = new Date(1_700_000_000_000) afterEach(async () => { await resetScheduledRunReadModelForTests() @@ -365,12 +366,13 @@ describe('scheduled run read model', () => { prompt: 'prompt', }] }) await fs.writeFile(sourcePath, serialize('run-a')) + await fs.utimes(sourcePath, exactFileTime, exactFileTime) const original = await fs.stat(sourcePath) expect((await readScheduledRunPage(sourcePath, { summaryOnly: true }))?.runs[0]?.id).toBe('run-a') await fs.writeFile(sourcePath, serialize('run-b')) - await fs.utimes(sourcePath, original.atimeMs / 1000, original.mtimeMs / 1000) + await fs.utimes(sourcePath, exactFileTime, exactFileTime) const restored = await fs.stat(sourcePath) expect(restored.size).toBe(original.size) expect(restored.mtimeMs).toBe(original.mtimeMs) @@ -397,11 +399,12 @@ describe('scheduled run read model', () => { output: `${'a'.repeat(half)}${middle}${'a'.repeat(half)}`, }] }) await fs.writeFile(sourcePath, serialize('a')) + await fs.utimes(sourcePath, exactFileTime, exactFileTime) const original = await fs.stat(sourcePath) await readScheduledRunPage(sourcePath, { summaryOnly: true }) await fs.writeFile(sourcePath, serialize('b')) - await fs.utimes(sourcePath, original.atimeMs / 1000, original.mtimeMs / 1000) + await fs.utimes(sourcePath, exactFileTime, exactFileTime) const restored = await fs.stat(sourcePath) expect(restored.size).toBe(original.size) expect(restored.mtimeMs).toBe(original.mtimeMs) @@ -426,13 +429,14 @@ describe('scheduled run read model', () => { output: `${'a'.repeat(half)}${middle}${'a'.repeat(half)}`, }] }) await fs.writeFile(sourcePath, serialize('a')) + await fs.utimes(sourcePath, exactFileTime, exactFileTime) const original = await fs.stat(sourcePath) await readScheduledRunPage(sourcePath, { summaryOnly: true }) setScheduledRunFingerprintAfterInitialStatHookForTests(async () => { setScheduledRunFingerprintAfterInitialStatHookForTests(null) await fs.writeFile(sourcePath, serialize('b')) - await fs.utimes(sourcePath, original.atimeMs / 1000, original.mtimeMs / 1000) + await fs.utimes(sourcePath, exactFileTime, exactFileTime) }) await readScheduledRunPage(sourcePath, { summaryOnly: true }) diff --git a/src/server/services/localIndex/sessionProjector.test.ts b/src/server/services/localIndex/sessionProjector.test.ts index 5cc1710b..0f4e83bf 100644 --- a/src/server/services/localIndex/sessionProjector.test.ts +++ b/src/server/services/localIndex/sessionProjector.test.ts @@ -24,6 +24,7 @@ import { import { verifySourceFingerprint } from './sourceFingerprint.js' const tempDirs: string[] = [] +const exactFileTime = new Date(1_700_000_000_000) async function createTempDir(label: string): Promise { const directory = await mkdtemp(join(tmpdir(), `cc-haha-${label}-`)) @@ -62,10 +63,12 @@ async function createCandidate(options: { projectPath: string sessionId: string content: string + fileTime?: Date }): Promise { const path = join(options.root, 'projects', options.projectPath, `${options.sessionId}.jsonl`) await mkdir(dirname(path), { recursive: true }) await writeFile(path, options.content) + if (options.fileTime) await utimes(path, options.fileTime, options.fileTime) const snapshot = await stat(path) return { path, @@ -669,6 +672,7 @@ describe('session projector', () => { projectPath: '-repo-a', sessionId: 'commit-ctime-race', content: beforeContent, + fileTime: exactFileTime, }) const originalSnapshot = await stat(candidate.path) const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) @@ -684,8 +688,8 @@ describe('session projector', () => { writeFileSync(candidate.path, afterContent) utimesSync( candidate.path, - originalSnapshot.atimeMs / 1_000, - originalSnapshot.mtimeMs / 1_000, + exactFileTime, + exactFileTime, ) }, }) @@ -719,6 +723,7 @@ describe('session projector', () => { projectPath: '-repo-a', sessionId: 'activity-commit-ctime-race', content: beforeContent, + fileTime: exactFileTime, }) const originalSnapshot = await stat(candidate.path) const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') }) @@ -734,8 +739,8 @@ describe('session projector', () => { writeFileSync(path, afterContent) utimesSync( path, - originalSnapshot.atimeMs / 1_000, - originalSnapshot.mtimeMs / 1_000, + exactFileTime, + exactFileTime, ) } return statSync(path) diff --git a/src/server/services/localIndex/sourceFingerprint.test.ts b/src/server/services/localIndex/sourceFingerprint.test.ts index eb1d3916..12e380e7 100644 --- a/src/server/services/localIndex/sourceFingerprint.test.ts +++ b/src/server/services/localIndex/sourceFingerprint.test.ts @@ -112,6 +112,7 @@ describe('source fingerprint transitions', () => { path, indexedBytes: Buffer.byteLength(original), parserVersion: 1, + identityResolver: stats => `${stats.dev}:${stats.ino}`, }) const replacedPath = `${path}.old` await rename(path, replacedPath) @@ -120,6 +121,7 @@ describe('source fingerprint transitions', () => { path, previous: beforeReplacement, parserVersion: 1, + identityResolver: stats => `${stats.dev}:${stats.ino}`, })).toEqual({ kind: 'rebuild', reason: 'replace' }) const beforeRewrite = await captureSourceFingerprint({ diff --git a/src/server/services/mcpHostPreflight.test.ts b/src/server/services/mcpHostPreflight.test.ts index c9678f4d..cc1bdbe2 100644 --- a/src/server/services/mcpHostPreflight.test.ts +++ b/src/server/services/mcpHostPreflight.test.ts @@ -68,7 +68,7 @@ describe('MCP host preflight', () => { await rm(tmpDir, { recursive: true, force: true }) }) - it('finds npx from user shell PATH when the desktop process PATH is minimal', async () => { + it.skipIf(process.platform === 'win32')('finds npx from user shell PATH when the desktop process PATH is minimal', async () => { const shellPath = path.join(tmpDir, 'zsh') const nodeBin = path.join(tmpDir, 'node-bin') const npxPath = path.join(nodeBin, 'npx') diff --git a/src/services/api/traceCapture.ts b/src/services/api/traceCapture.ts index a17c4ccb..1620bcf8 100644 --- a/src/services/api/traceCapture.ts +++ b/src/services/api/traceCapture.ts @@ -821,8 +821,9 @@ export async function captureResponseTraceSnapshot( if (completed) return interrupted = true void reader.cancel().catch(() => {}) + // This promise awaits the backstop; keep its timer referenced so Bun on + // Windows can fire it even when the pending stream read never settles. graceTimer = setTimeout(() => resolve('forced'), options?.abortGraceMs ?? TRACE_ABORT_CAPTURE_GRACE_MS) - graceTimer.unref?.() } if (signal.aborted) onAbort() else signal.addEventListener('abort', onAbort, { once: true }) diff --git a/src/utils/mcpStdioEnvironment.test.ts b/src/utils/mcpStdioEnvironment.test.ts index bee53071..1f5c1bea 100644 --- a/src/utils/mcpStdioEnvironment.test.ts +++ b/src/utils/mcpStdioEnvironment.test.ts @@ -70,7 +70,7 @@ describe('MCP stdio environment', () => { await rm(tmpDir, { recursive: true, force: true }) }) - it('adds PATH entries sourced from the user zshrc when MCP env has no explicit PATH', async () => { + it.skipIf(process.platform === 'win32')('adds PATH entries sourced from the user zshrc when MCP env has no explicit PATH', async () => { const shellPath = path.join(tmpDir, 'zsh') const nodeBin = path.join(tmpDir, 'node-bin') await mkdir(nodeBin, { recursive: true }) diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts index 4f75f54d..5b28a498 100644 --- a/src/utils/proxy.test.ts +++ b/src/utils/proxy.test.ts @@ -25,12 +25,12 @@ describe('proxy environment handling', () => { afterEach(restoreEnv) test('bypasses proxy fetch options for loopback provider proxy targets', () => { - process.env.HTTP_PROXY = 'http://127.0.0.1:1181' - process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' - process.env.NO_PROXY = 'localhost,127.0.0.1,::1' delete process.env.http_proxy delete process.env.https_proxy delete process.env.no_proxy + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = 'localhost,127.0.0.1,::1' expect(shouldBypassProxy('http://127.0.0.1:3456/proxy/providers/p1/v1/messages')).toBe(true) expect(getProxyFetchOptions({ @@ -40,12 +40,12 @@ describe('proxy environment handling', () => { }) test('bypasses bracketed IPv6 loopback targets for plain ::1 NO_PROXY entries', () => { - process.env.HTTP_PROXY = 'http://127.0.0.1:1181' - process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' - process.env.NO_PROXY = '::1' delete process.env.http_proxy delete process.env.https_proxy delete process.env.no_proxy + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = '::1' expect(shouldBypassProxy('http://[::1]:3456/api/status')).toBe(true) expect(getProxyFetchOptions({ @@ -66,12 +66,12 @@ describe('proxy environment handling', () => { }) test('keeps proxy fetch options for external provider targets', () => { - process.env.HTTP_PROXY = 'http://127.0.0.1:1181' - process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' - process.env.NO_PROXY = 'localhost,127.0.0.1,::1' delete process.env.http_proxy delete process.env.https_proxy delete process.env.no_proxy + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = 'localhost,127.0.0.1,::1' expect(shouldBypassProxy('https://api.example.com/v1/messages')).toBe(false) expect(getProxyFetchOptions({ diff --git a/src/utils/terminalShellEnvironment.test.ts b/src/utils/terminalShellEnvironment.test.ts index fdf39e27..6146659b 100644 --- a/src/utils/terminalShellEnvironment.test.ts +++ b/src/utils/terminalShellEnvironment.test.ts @@ -45,7 +45,7 @@ describe('terminal shell environment', () => { await rm(tmpDir, { recursive: true, force: true }) }) - it('captures exported variables from an interactive user shell', async () => { + it.skipIf(process.platform === 'win32')('captures exported variables from an interactive user shell', async () => { const shellPath = path.join(tmpDir, 'zsh') const nodeBin = path.join(tmpDir, 'node-bin') const nvmDir = path.join(tmpDir, '.nvm') @@ -72,20 +72,22 @@ describe('terminal shell environment', () => { }) it('merges shell PATH before base PATH while preserving app env overrides', () => { + const basePath = ['/usr/bin', '/bin'].join(path.delimiter) + const shellPath = ['/opt/homebrew/bin', '/usr/bin'].join(path.delimiter) const merged = mergeTerminalShellEnvironment( { - PATH: '/usr/bin:/bin', + PATH: basePath, CC_HAHA_DESKTOP_SERVER_URL: 'http://127.0.0.1:3456', TOOL_HOME: '/base/tool', }, { - PATH: '/opt/homebrew/bin:/usr/bin', + PATH: shellPath, NVM_DIR: '/Users/test/.nvm', TOOL_HOME: '/shell/tool', }, ) - expect(merged.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin') + expect(merged.PATH).toBe(['/opt/homebrew/bin', '/usr/bin', '/bin'].join(path.delimiter)) expect(merged.NVM_DIR).toBe('/Users/test/.nvm') expect(merged.TOOL_HOME).toBe('/base/tool') expect(merged.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456') diff --git a/tests/manual/v0.4.10-to-head-ui-errors.md b/tests/manual/v0.4.10-to-head-ui-errors.md new file mode 100644 index 00000000..f8c0547a --- /dev/null +++ b/tests/manual/v0.4.10-to-head-ui-errors.md @@ -0,0 +1,305 @@ +# v0.4.10 之后人工界面测试缺陷与修复记录 + +> 测试范围:`v0.4.10..91829e91` +> +> 平台:Windows 11 x64;隔离 `CLAUDE_CONFIG_DIR` 与 Electron `user-data-dir` +> +> 状态:本轮执行已收口。DeepSeek、MiniMax 真实编程任务、附件恢复、多模态、SubAgent、Task 并发与 Windows 长空闲恢复均有真实 UI 证据;BUG-001~BUG-011 已修复并验证。未执行或仅部分执行的硬件、故障注入、CLI/TUI 用例仍按限制保留,不冒充 passed。 + +## BUG-001|React StrictMode 下内置终端首次打开为空白 + +- 严重程度:P0 +- 状态:fixed / verified +- 对应用例:WIN-03、终端基础冒烟 +- 测试场景:开发构建中打开顶部内置终端标签。 +- 实际结果:终端区域为空白且没有可用会话;刷新设置页不能恢复。 +- 期望结果:终端只创建一个 runtime,显示 `Running`、shell、cwd 和可交互提示符。 +- 根因:React StrictMode 首次挂载会重放 effect。第一次 cleanup 立即销毁共享 terminal runtime,第二次 effect 继续持有已经失效的对象,因而无法重新启动。 +- 修复:为组件 effect 增加生命周期版本;普通真实卸载仍在 microtask 中销毁 runtime,StrictMode replay 的过期 cleanup 则不再销毁当前 runtime。 +- 变更文件: + - `desktop/src/pages/TerminalSettings.tsx` + - `desktop/src/pages/TerminalSettings.test.tsx` +- 验证证据: + - 聚焦 Vitest:17/17 passed。 + - TypeScript `tsc --noEmit` passed。 + - Computer Use 复验:顶部终端显示 `Running`、`C:\WINDOWS\system32\cmd.exe` 和隔离配置目录提示符;Restart 后仍为 `Running`。 + - 完整 `bun run check:desktop`:2430 passed、2 skipped、0 failed,production build passed。 + +## BUG-002|切换语言后 Settings 顶部标签仍保留旧中文标题 + +- 严重程度:P1 +- 状态:fixed / verified +- 对应用例:I18N-01 +- 测试场景:应用最初为简体中文,打开设置标签后切换到 English。 +- 实际结果:设置页正文已经变为英文,但顶部标签仍显示持久化的“设置”。 +- 期望结果:顶部标签与当前 locale 同步显示 `Settings`,关闭按钮的无障碍名称也同步更新。 +- 根因:设置标签直接渲染创建标签时保存的 `tab.title`,没有根据当前 locale 重新读取 `settings.title`。 +- 修复:设置类型标签动态渲染当前 `t('settings.title')`,同时用于 close button 的 aria-label;普通会话标签仍保留原标题。 +- 变更文件: + - `desktop/src/components/layout/TabBar.tsx` + - `desktop/src/components/layout/TabBar.test.tsx` +- 验证证据: + - 聚焦 Vitest:40/40 passed。 + - TypeScript `tsc --noEmit` passed。 + - Computer Use 复验:切换 English 后顶部显示 `Settings`,关闭按钮为 `Close Settings`。 + - 完整 `bun run check:desktop`:passed。 + +## QA-001|Windows 完整桌面门禁的跨平台测试夹具失败 + +- 严重程度:测试基础设施 +- 状态:fixed / verified +- 首次结果:`bun run check:desktop` 为 12 failed、2418 passed,因测试失败未进入最终 build。 +- 分类: + - 7 个稳定失败来自测试夹具把 POSIX/macOS 路径、权限位或 `PATH` 大小写写死到 Windows。 + - 2 个 `petWindow` 失败来自源码字符串断言写死 LF,不能处理 Windows CRLF。 + - 3 个 Workspace 测试在全量并发下渲染约 5001、1996、2300 个 DOM 行,触发 5/20 秒超时;聚焦运行均通过。 +- 修复原则:没有发现对应生产逻辑错误,因此只修正测试的跨平台构造和无必要的大规模 DOM 夹具,没有改生产代码,也没有简单扩大超时。 +- 变更文件: + - `desktop/scripts/electron-output-guard.test.ts` + - `desktop/electron/services/pets.test.ts` + - `desktop/electron/services/sidecarManager.test.ts` + - `desktop/electron/services/terminal.test.ts` + - `desktop/electron/services/petWindow.test.ts` + - `desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx` + - `desktop/src/components/workspace/WorkspacePanel.test.tsx` +- 验证证据: + - 路径/权限聚焦测试:98 passed、1 macOS-only skipped。 + - petWindow/Workspace 聚焦测试:95/95 passed;同组 coverage 运行 95/95 passed。 + - 修复后完整 `check:desktop`:2430 passed、2 skipped、0 failed;最终 `tsc -b` 与 Vite production build passed。 + - 紧接着的全量 JSON 复跑:524/524 suites passed,2430 passed、2 skipped。 + +## 排除的环境误报 + +### ENV-001|旧 Sidecar 导致 Pets 无法加载 + +- 初始现象:设置页显示“无法加载宠物”。 +- 结论:不是候选代码缺陷。Electron 主进程使用当前源码,但启动时加载的是 2026-07-18 的旧 `claude-sidecar` 二进制,早于测试范围内的服务端改动。 +- 处置:备份旧二进制后执行 `bun run build:sidecars`,仅在隔离测试实例中换入当前 Sidecar。 +- 复验:4 个内置宠物 Dada、Huhu、Bubu、Huihui 正常出现;桌面宠物窗口可打开。 + +### ENV-002|旧 Sidecar 导致 Agent 保存后前端读取 `agentType` 报错 + +- 初始现象:创建 Agent 后弹出 `Cannot read properties of undefined (reading 'agentType')`。 +- 结论:不是候选代码缺陷。旧 Sidecar 的 `POST /api/agents` 返回 `{ok:true}`,当前桌面端期望 `{agent: ...}`。 +- 处置:同 ENV-001,重建当前 Sidecar。 +- 复验:用户 Agent `manual_reviewer` 可创建、查看、编辑回继承;内置 Agent 保持只读。 + +## BUG-003|权限模式控制请求被拒绝后遗留 pending confirmation + +- 严重程度:P1 +- 状态:fixed / verified +- 对应用例:DENY-01、DENY-02、权限模式切换 +- 发现方式:Windows `check:coverage` 在连续执行“确认成功 → CLI 拒绝 → 确认超时”三个权限模式测试时稳定不退出。 +- 根因:`ConversationService.setPermissionMode()` 在创建 confirmation Promise 后调用 `requestControl()`;CLI 拒绝 control request 时,catch 只清理 timer/callback,没有 settle 已创建的 confirmation Promise。在 coverage 异步跟踪下,该 pending Promise 会让后续计时测试进入持续 CPU 状态。 +- 修复:保存 confirmation reject 句柄,引入 settled guard 和显式 `cancelConfirmation(reason)`;control request 失败时清理 callback/timer、拒绝 confirmation,并原样重新抛出原始 CLI 错误。确认超时计时器只在 control request 成功后按总预算剩余时间启动。 +- 变更文件: + - `src/server/services/conversationService.ts` + - `src/server/__tests__/conversations.test.ts` +- 验证证据: + - 三个权限模式顺序回归:3 passed、0 failed。 + - 同组三用例 coverage:3 passed、0 failed,不再挂起。 + - 完整 `conversations.test.ts` coverage:96 个具名测试全部完成并通过;Windows afterAll 临时目录清理另有一次 `EBUSY`,不属于行为断言失败。 + - 回归额外断言 CLI 拒绝后 `outputCallbacks` 为空、`pendingPermissionModeChanges` 为空。 + +## QA-002|Windows `check:coverage` 的 root-server 阶段异常慢 + +- 严重程度:测试基础设施 +- 状态:fixed / verified +- 实际结果:单次 `bun run check:coverage` 在第一个 `root-server` 覆盖率套件持续 22 分钟,没有生成 `coverage.log` 或 lcov 产物;Bun 进程保持约单核满载、约 500 MB working set 且响应正常。 +- 历史对照:同机历史 coverage 报告中 root-server 阶段约为 135、151、190 秒。 +- runner 限制:`scripts/quality-gate/coverage.ts` 会把整个子进程 stdout/stderr 缓冲到退出后再写日志,且没有套件级 timeout,所以执行中没有可用于定位最后用例的进度产物。 +- 处置:确认 PID、父子关系和完整 command line 后,仅终止本次 `check:coverage` 的三个 Bun 进程;没有启动重复运行,也没有影响 Vite、Electron 或其他测试。 +- 根因定位:连续权限模式用例会触发 BUG-003 的 pending confirmation 泄漏;定向 coverage 修复后不再挂起。 +- 结论:首次覆盖率门禁记为 blocked/timeout;修复后完整重跑在 130.2 秒内完成,5/5 suites passed,changed lines 95.56%(43/45)。 + +## BUG-004|Bun 下销毁 CONNECTING WebSocket 会永久停在 CLOSING + +- 严重程度:P1 +- 状态:fixed / verified +- 对应用例:WIN-05 +- 发现方式:修复 BUG-003 后,完整 coverage 的 adapters 阶段为 405 passed、1 failed;`WsBridge > destroy cleans up all sessions without leaking connecting-socket errors` 在 20ms 后仍保留一个 error listener。 +- 根因:Bun 1.3.11 会把 bare `ws` 导入替换为 BunWebSocket 兼容层。对仍为 CONNECTING 的不可达连接立即调用 `close()` 或 `terminate()`,socket 会永久停在 CLOSING,且不再触发 error/close;原临时 error sink 和 close listener 因而永久保留。 +- 修复:CONNECTING 状态不再调用会毒化状态的 close/terminate。移除业务 listener 后只挂 teardown listener:自然 error 被消费并清理;若 open 先发生,则再正常 close,并在 close/error 时清理。OPEN/CLOSING/CLOSED 的原关闭路径保持不变;生产代码没有新增任意超时。 +- 变更文件: + - `adapters/common/ws-bridge.ts` + - `adapters/common/__tests__/ws-bridge.test.ts` +- 验证证据: + - 聚焦测试:13 passed、0 failed。 + - 聚焦 coverage:13 passed、0 failed;`ws-bridge.ts` lines 85.59%、functions 73.68%。 + - 回归按条件等待 CLOSED,并断言 open/error/close listeners 全部为 0。 + - `bun run check:adapters`:406 passed、0 failed。 + - 修复后完整 `bun run check:coverage`:5/5 suites passed。 + +## BUG-005|Trace abort backstop timer 被 unref 后在 Bun/Windows 单核自旋 + +- 严重程度:P1 +- 状态:fixed / verified +- 对应用例:Trace/诊断稳定性、server 质量门禁 +- 发现方式:`check:server` 卡在 `trace-capture.test.ts`;拆分单文件后,在任何测试结果输出前持续占满一个 CPU 核心。 +- 根因:`captureResponseTraceSnapshot` 对无法被 cancel 唤醒的永久 pending `reader.read()` 使用 grace backstop timer,但该 timer 随即 `unref()`。Bun 1.3.11 Windows 在 `Promise.race` 只剩 pending read 与 unref timer 时会同步忙循环,timer 不触发。 +- 修复:保留 backstop timer 的 event-loop 引用,并注释说明它是当前函数正在等待的正确性兜底,不能 unref。 +- 变更文件:`src/services/api/traceCapture.ts` +- 验证证据: + - 原挂起用例:1/1 passed,63ms。 + - 完整 `trace-capture.test.ts`:61/61 passed、281 assertions,Bun 2.67 秒,自然退出。 + - `bun run check:server`:219 files、2248 passed、0 failed。 + - `bun run check:chat-contract`:5 files、299 passed、0 failed。 + +## QA-003|Reconciliation safety sweep 测试等待 unref interval 时忙循环 + +- 严重程度:测试基础设施 +- 状态:fixed / verified +- 实际结果:前 5 个测试显示 passed 后进程持续单核满载,没有最终汇总;定位到第 6 个 low-frequency safety sweep 用例。 +- 根因:测试 `await` 的 Promise 只能由 production 中已 `unref()` 的 safety interval resolve;pending Promise 不保持事件循环活跃,在没有 referenced timer 时 Bun runner 忙等,测试也无法走到 `watcher.stop()`。 +- 修复:用已有带 referenced `Bun.sleep` 的 `waitFor` 等待首个 batch,并用 `try/finally` 始终释放被阻塞的首批次、停止 watcher。 +- 变更文件:`src/server/services/localIndex/reconciliationWatcher.test.ts` +- 验证证据:单用例 1/1 passed、191ms;完整文件 7/7 passed、570ms,自然输出汇总并退出。 + +## BUG-006|Windows 下停止系统代理时 CONNECT 连接使 Electron 测试超时 + +- 严重程度:P1 / Windows 资源生命周期 +- 状态:fixed / verified +- 触发用例:WIN-04;`check:native` 内的 `SystemProxyBridge > closes active CONNECT clients and outbound routes during stop`。 +- 实际结果:Vitest 单文件通过,但 native 使用的 Bun test runner 中 `bridge.stop()` 稳定超过 500ms。 +- 根因:Bun/Windows 对已升级的 CONNECT socket,仅销毁桥接层跟踪的 socket 不足以让 `http.Server.close()` callback 完成。 +- 修复:先销毁已跟踪的 client/outbound sockets;仅在 server 仍处于 listening 时调用 `closeAllConnections()`,然后调用并等待 `server.close()`。保留 listening guard,避免破坏 startup/stop 竞态。 +- 变更文件:`desktop/electron/services/systemProxyBridge.ts` +- 验证证据:Bun 聚焦测试 18/18 passed;Electron TypeScript passed;完整 `check:native` 中 Electron 310 passed、1 skipped、0 failed,目录打包与 current package smoke passed。 + +## QA-004|Tray 测试与 Menu 测试共享 Electron 模块 mock + +- 严重程度:测试基础设施 +- 状态:fixed / verified +- 触发用例:`check:native` 内 `tray.test.ts` 的未处理错误 `Electron tray mocks were not initialized for this test`。 +- 实际结果:tray 单文件 4/4 passed;与 menu 测试联跑时稳定失败,证明是测试加载顺序相关污染。 +- 根因:Bun 整套 Electron 测试共享 `electron` 模块 mock 注册表,`menu.test.ts` 与 `tray.test.ts` 会互相覆盖模块级 mock。 +- 修复:`installTray` 接受可选且类型受限的 Electron runtime 注入,生产默认仍动态导入 Electron;tray 测试改为注入本地 mocks,不再注册全局 `vi.mock('electron')`。 +- 变更文件:`desktop/electron/services/tray.ts`、`desktop/electron/services/tray.test.ts` +- 验证证据:tray 4/4 passed;menu + tray 15/15 passed;Electron TypeScript passed;完整 `check:native` passed。 + +## BUG-007|项目 Agent 保存被可选热重载阻塞 120 秒 + +- 严重程度:P1 / Agent 管理可用性 +- 状态:fixed / verified +- 触发用例:AGT-03、AGT-08。 +- 实际结果:Project Agent 的文件和列表刷新已完成,但 Create Agent 对话框仍保持 Save/Cancel disabled 与 loading;120 秒后才关闭并显示 `The change was saved, but the latest agent configuration could not be fully applied. Request timed out after 120s`。 +- 现场证据:首次隔离工作区位于外层 Git 仓库内,服务端按既有 Git-root 规则将文件写到仓库 `.claude/agents/project_reviewer.md`;该测试文件及本轮创建的空 `agents` 目录已精确清理。随后在 `artifacts/manual-ui-91829e91/workspace` 内初始化独立 Git 根,后续复测不会写入产品仓库。 +- 根因:`desktop/src/stores/agentStore.ts` 在已成功 create/list 后仍等待 `agentsApi.reload()`;reload 请求使用 120 秒 timeout,并在结束前保持全局 `isMutating=true`。热重载本应是保存后的非阻塞增强,失败只需 warning + Retry。 +- 修复:create/update/delete 在持久化和列表刷新成功后立即结束 mutation;session reload 改为后台 warning,并用 request id 隔离项目切换后的迟到结果。 +- 验证证据:AgentManager + agentStore 44/44 passed,desktop lint passed;Computer Use 复测点击 Save 后约 1.2 秒进入 Agent 详情页,提示“active CLI session is not running”但 Save/Cancel 不再阻塞。 + +## BUG-008|Agent Git 根缓存未感知运行中新增的嵌套仓库 + +- 严重程度:P1 / 项目隔离与数据安全 +- 状态:fixed / verified +- 触发用例:AGT-03。 +- 实际结果:Create Agent 表单显示目标为 `artifacts/manual-ui-91829e91/workspace`,且该目录已通过 `git init` 成为独立 Git 根;保存后文件仍被写入外层产品仓库 `.claude/agents/project_reviewer_fixed.md`。 +- 现场证据:`git -C artifacts/manual-ui-91829e91/workspace rev-parse --show-toplevel` 返回隔离工作区;隔离目录无 Agent 文件,外层文件的创建时间与本轮保存一致。已读取并核对内容后精确删除该测试文件。 +- 根因:Agent 服务首次解析隔离目录时它尚未初始化为仓库,`findGitRoot(realCwd)` 的全局缓存记录了外层仓库;运行中 `git init` 后 Agent CRUD 仍复用旧缓存,且 `clearAgentDefinitionsCache()` 不会清理 Git-root 缓存。 +- 修复:项目 Agent 路径解析前按 `realCwd` 刷新 Git-root 缓存;新增“先预热父仓库、再创建嵌套 `.git`”的 API 回归测试。 +- 验证证据:新增用例在临时撤掉修复时按预期失败,恢复后 `agents-api.test.ts` 17/17 passed、260 assertions。重建 Sidecar 并重启隔离 Electron 后,Computer Use 创建 `project_reviewer_fixed2`,约 1.5 秒进入详情页且目标显示隔离工作区;Shell 确认文件只存在于 `artifacts/manual-ui-91829e91/workspace/.claude/agents/`,外层产品仓库同名文件不存在。 + +## BUG-009|Windows 本地半关闭连接堆积并拖垮 Session 列表 + +- 严重程度:P0 / Windows 桌面核心可用性 +- 状态:fixed / focused + live Windows verified +- 触发用例:WIN-06、CTX-01、会话列表与 Scheduled 冒烟。 +- 实际结果:左侧短暂显示 `Session list failed to load` / `Request timed out after 120s`,稍后刷新又自行消失。同期并非只有 Session:16:59:19 的 Task list 与 turn-checkpoints 同时 120 秒超时;17:04:01 的 `/api/sessions?limit=400` 与 `/api/scheduled-tasks` 同时 120 秒超时,之前分别有两次 context inspection 20 秒超时。 +- Windows 现场证据:Sidecar 和 CLI 进程始终存活,故障窗口无 Electron/Bun crash;端口 51529 残留 9 对异常连接,Electron 侧为 `FinWait2`、Sidecar 侧为 `CloseWait`。Session 目录只有 3 个小 JSONL,本地 SQLite 也很小,不支持“400 条 Session 扫描过慢”的解释。 +- 首轮根因假设:`ContextUsageIndicator` 的 HTTP deadline 与服务端 `get_context_usage` control budget 同为 20,000 ms,客户端 abort 与服务端 timeout 同刻竞速,使 localhost keep-alive socket 未及时收尾。该假设只能解释早期相关性,不是最终根因。 +- 最终根因:Electron/Chromium 会长期池化 HTTP/1.1 本地连接,而 `Bun.serve` 配置 `idleTimeout: 60`。Chromium netlog 证明失败的 source 4974 在 socket #1319 空闲约 104 秒后复用:此前该 socket 多次正常收发,复用时客户端成功写入 684 bytes,服务端却不再返回响应头,120,011 ms 后 Chromium 取消。Windows 上 Bun 超时后的 socket 没有被客户端识别为已关闭,形成 stale keep-alive;任务、turn-checkpoints、workspace-status 等任意复用该连接的 REST 都会受影响。 +- 修复:保留客户端/abort/health 的正确生命周期修复,并将本地 `Bun.serve` HTTP `idleTimeout` 设为 `0`,让 Chromium 客户端拥有池化连接生命周期,避免 server 先静默失效而 client 继续复用。曾尝试给所有 REST 响应加 `Connection: close`,但 12 次导航制造 403 个 `TIME_WAIT`,已撤回该全局策略。 +- 变更文件: + - `desktop/src/components/chat/ContextUsageIndicator.tsx` + - `desktop/src/components/chat/ContextUsageIndicator.test.tsx` + - `desktop/src/api/client.ts` + - `desktop/src/api/client.test.ts` + - `desktop/src/stores/cliTaskStore.ts` + - `desktop/src/stores/cliTaskStore.test.ts` + - `desktop/electron/services/sidecarManager.ts` + - `desktop/electron/services/sidecarManager.test.ts` + - `src/server/index.ts` + - `src/server/__tests__/server-options.test.ts` + - `src/server/requestLifecycle.ts` + - `src/server/api/sessions.ts` + - `src/server/services/conversationService.ts` + - `src/server/__tests__/conversations.test.ts` +- 验证证据: + - 服务端 abort callback 清理:1/1 passed;服务端 timeout listener 清理:1/1 passed。 + - ContextUsageIndicator 聚焦 Vitest:7/7 passed。 + - 重建 Sidecar、重启同一隔离 Electron 后,真实 DeepSeek 返回 `WINDOWS_TIMEOUT_FIX_ACTIVE`,会话列表与 context 2% 正常。 + - 修复版基线为 0 `CloseWait` / 0 `FinWait2`;Computer Use 连续手动刷新 context 10 次后仍为 0 / 0,仅有正常 `Established` 和短期 `TimeWait`。 + - 随后会话列表手动刷新约 1.3 秒,Scheduled 页面约 1.3 秒打开,均无 timeout;这只证明了短时场景。 + - 最终定位前的重启复现中,真实 MiniMax 多模态会话跨过 120 秒后再次出现 `CloseWait/FinWait2`,并在稍后明确写入 3 条新的 120 秒诊断:task-list、turn-checkpoints、workspace-status;这批证据用于否定“只是 context 计算慢”。 + - `server-options.test.ts`、request-lifecycle 与 tasks 聚焦回归合计 14 passed / 0 failed / 27 expects;Sidecar 重新编译成功。 + - 修复版真实 Electron 启动后先空闲 85 秒,随后 MiniMax 在同一轮并发执行 2 个 TaskCreate、TaskList + 2 个 TaskGet、2 个 TaskUpdate 和最终 TaskList,UI 两项均为绿色 completed,模型返回 `TASK_PARALLEL_IDLE0_OK`。 + - 修复版 netlog 中同一路径最慢 235 ms,所有 localhost API 最慢 578 ms;运行 137 秒后 sidecar 仍只有正常 `Established`,0 `CloseWait` / 0 `FinWait2`,诊断日志无新增 timeout。WIN-06 改为 passed。 + - 使用官方 `@oven/bun-windows-x64-baseline@1.3.14` binary 重编译 sidecar;真实 Electron 空闲 106 秒后 MiniMax 返回 `IDLE0_BUN1314_OK`,运行 131 秒仍为 0 `CloseWait` / 0 `FinWait2` 且无新增 timeout,排除仅在系统 Bun 1.3.11 上偶然通过。 + - `cliTaskStore` 新增同一 session 重叠轮询合并;聚焦 12/12 passed,避免 React/轮询重入放大本地连接压力,但它不是 stale socket 根因的替代修复。 +- 剩余风险:H5 开启时 sidecar 监听 `0.0.0.0`,`idleTimeout: 0` 也会允许尚未发送完整 HTTP headers 的裸 TCP 连接长期占用资源。当前 H5 token 鉴权只发生在形成 HTTP 请求之后;若要彻底收敛 slowloris/资源占用风险,应将桌面 loopback 与 LAN H5 listener 拆分,或增加 pre-header 连接级 deadline。此架构项不影响本轮 localhost timeout 修复结论,但发布时需要风险接受或后续整改。 + +## BUG-010|Windows 宠物窗口重启后失去置顶层级 + +- 严重程度:P1 / Windows 桌面宠物可见性 +- 状态:fixed / focused + live Windows verified +- 触发用例:PET-01、PET-02。 +- 实际结果:完整重启隔离 Electron 后,宠物窗口存在且 shaped region 正确,但被 ChatGPT 等普通窗口遮住;用户只能偶尔看到很小的光标/透明边缘,容易误判为宠物图片资源缺失。 +- 排除项:当前配置选择的是仓库内置 `dada-code`,4 个内置宠物资源均存在且与 HEAD 一致。另一台电脑导入的猫/狗宠物位于 `${CLAUDE_CONFIG_DIR}/cc-haha/pets`,属于本地用户状态,不会通过 Git 自动同步;本次故障不是资源丢失或 fallback。 +- Windows 现场证据:修复前宠物 Win32 z-rank 285,ChatGPT 为 39,窗口样式 `topmost=false`;宠物 mascot region 为 `96,116,288,322`,任务卡 region 为 `12,322,372,388`,证明渲染区域存在但层级错误。 +- 根因:`BrowserWindow` 构造时的 `alwaysOnTop: true` 在 Windows `showInactive()` 后未保持;代码只在 macOS 分支重新调用 `setAlwaysOnTop`。 +- 修复:`showInactive()` 后,macOS 继续使用 `setAlwaysOnTop(true, 'floating')`,Windows 等其他平台显式调用 `setAlwaysOnTop(true)`。 +- 变更文件: + - `desktop/electron/services/petWindow.ts` + - `desktop/electron/services/petWindow.test.ts` +- 验证证据: + - `bun run test -- --run electron/services/petWindow.test.ts`:24/24 passed(连续两次)。 + - `bun run build:electron`:passed;重启隔离 Electron 两次。 + - 修复后宠物 `topmost=True`、z-rank 13,高于主窗口 30 与 ChatGPT 43;截图/可访问性树可见 192px Dada 和“暂无进行中的任务 / 收起 0 个任务”。 + - 后续 BUG-011 修复原生 shape 与前端层级后,Computer Use 已能真实鼠标命中任务圆点和关闭按钮;宠物本体拖动与多显示器位置恢复仍未完成,PET-02 继续保持 partial。 + +## BUG-011|宠物任务卡顶部被原生 shape 裁切,关闭按钮被宠物透明命中区截获 + +- 严重程度:P1 / Windows 宠物任务面板可用性 +- 状态:fixed / focused + live Windows verified +- 触发用例:PET-02、PET-04;用户截图显示卡片顶部圆角/阴影缺失,点击卡片下方箭头没有反应。 +- 实际结果:任务卡 DOM 矩形紧贴 Windows `BrowserWindow.setShape()` 边界,圆角抗锯齿与阴影越界部分被原生窗口裁切;关闭按钮绝对定位在卡片 DOM 矩形外,最初既不在原生命中 shape 内,又处在 `z-index: 5` 的卡片 stacking context 中,低于 `z-index: 10` 的宠物透明按钮区域。鼠标点箭头实际聚焦宠物按钮,因此卡片不关闭。 +- 排查证据: + - 修复前真实鼠标点击箭头后 UI 无变化,隔离配置 `showTaskPanel` 仍为 `true`。 + - 同一界面用 Tab 将焦点移到关闭按钮并按 Enter,卡片立即隐藏且 `showTaskPanel` 变为 `false`,排除 React handler 和偏好持久化故障。 + - 修复层级前鼠标点击箭头后,下一次 Tab 焦点从宠物按钮移到任务行,证明点击被宠物透明命中区截获。 +- 修复: + - Windows/Linux 原生交互 shape 对每个渲染区域增加 12px 安全边距并钳制在 384×400 窗口内,保留圆角、边框抗锯齿和阴影。 + - 将独立关闭按钮注册为原生命中区域,并纳入 renderer 的交互点/透传判断。 + - 将任务卡 stacking context 提升到 `z-index: 15`,高于宠物按钮的 `z-index: 10`,让画在宠物上方的关闭按钮也实际接收鼠标事件。 +- 变更文件: + - `desktop/electron/services/petWindow.ts` + - `desktop/electron/services/petWindow.test.ts` + - `desktop/src/features/pets/PetApp.tsx` + - `desktop/src/features/pets/PetApp.test.tsx` + - `desktop/src/theme/globals.css` + - `desktop/src/theme/globals.test.ts` +- 验证证据: + - 原生 shape 回归 24/24 passed;PetApp + globals 聚焦回归 24/24 passed。 + - 真实 Windows Electron + DeepSeek 编程任务中,卡片四角、顶部边框和阴影完整可见。 + - Computer Use 真实鼠标完成“数字圆点展开 → 箭头关闭 → 数字圆点恢复”两轮;第二轮无键盘辅助或 DOM 注入。 + - 隔离配置最终为 `showTaskPanel: false`,证明关闭状态已真实落盘。 + +## 当前测试限制 + +- LIVE-001(closed):用户明确授权真实 Provider 额度并提供专用测试配置。隔离 Electron 中 DeepSeek 连接测试 179 ms 成功,`deepseek-v4-pro` 已设为默认;真实回复 `LIVE_DEEPSEEK_OK`、拒绝权限后的 `CHAT_CONTINUES` 和修复后重启的 `WINDOWS_TIMEOUT_FIX_ACTIVE` 均通过。配置与证据只保留在隔离测试目录,本文不记录密钥。 +- PROVIDER-001(closed / false negative):MiniMax-M3 的 Anthropic 兼容接口确实支持 image block。此前出现 `[Unsupported Image]` 的会话复用了由 DeepSeek 产生该文本的历史 assistant 消息,并把同一图片重复附加,不能作为 MiniMax 不支持图片的证据。使用首轮即选 MiniMax-M3 的全新会话、只附一张 `canary.png` 后,真实模型准确返回 `PDF_CANARY_91829E91`;trace 为 `/anthropic/v1/messages`、1 个 image block、HTTP 200,prompt 仅出现 1 次,请求和响应均无 `[Unsupported Image]`。离线附件序列化回归 `conversation-attachments.test.ts` 为 3 passed / 0 failed / 22 expects。MiniMax 连接测试为 1,349 ms,本文不记录密钥。 +- LIVE-002(closed):真实 MiniMax-M3 编程流程调用 TaskCreate/List/Get/Update、SubAgent、Read/Bash 并运行 `bun test`,3 个任务全部完成、测试 2 passed / 0 failed,最终返回 `REAL_TASK_AGENT_FLOW_OK`。真实 DeepSeek 流程同样完成 3 个 Task V2、真实编辑和 `bun test`,最终返回 `DEEPSEEK_TASK_V2_OK 2 pass, 0 fail`。这两条均为隔离 Git 工作区的实际 Provider 流程,不是 mock。 +- PLAT-001:项目声明的 Electron 42.4 本地二进制不可用,本轮交互暂用已缓存的 Electron 42.3。最终构建与源码门禁不受此回退影响,但 Electron 版本特异风险会在最终结论中单独保留。 +- NATIVE-001(closed):`check:native` 首次执行在替换 Windows sidecar 时因隔离 Electron 遗留进程持有 exe 而 `EPERM`。核实进程链为隔离测试的 `bun 39396 → electron 29132 → sidecar 44260` 后仅结束该实例;清理后门禁进入 Electron 套件并暴露 BUG-006 / QA-004,修复后完整重跑 passed。 + +## 最终门禁汇总 + +- `bun run check:impact`:policy blocked;正确识别 adapters、cli-core、desktop、server,并要求 desktop、server、provider-contract、chat-contract、adapters、native、coverage。唯一 blocker 是 CLI core 变更需要 PR 标签与维护者批准,不是测试失败。 +- `bun run check:desktop`:passed;完整 Vitest、lint 与 production Vite build 均成功。首轮仅因旧断言仍期待 20 秒 context timeout 而失败;更新为当前 30 秒契约后聚焦 30/30 passed,完整门禁重跑通过。 +- `bun run check:server`:passed;221 files、2254 passed、0 failed。首次与其他大门禁并行时 `conversations.test.ts` 单例触发 10 秒资源竞争超时;单例 203 ms 通过,随后完整串行重跑通过。 +- `bun run check:provider-contract`:passed;18 个 deterministic suites 全部通过。 +- `bun run check:chat-contract`:passed。 +- `bun run check:adapters`:passed;406 passed、0 failed、1091 assertions。 +- `bun run check:native`:passed;sidecar compiled smoke 8/8,Electron 311 passed、1 个 macOS-only skipped、0 failed;Electron production build、Windows x64 目录打包及 current package smoke 均通过。一次 1 秒命令超时是执行器主动中止,不是代码失败,完整重跑 84 秒成功。 +- `bun run check:coverage`:passed;5/5 groups、0 failed,报告位于 `artifacts/coverage/2026-07-22T11-11-42-208Z/coverage-report.md`。 +- 最终工作区核对:`git diff --check` passed;`git diff --stat` 为 53 个已跟踪文件、931 insertions / 301 deletions(另有本轮新增测试与两份人工文档);`git status --short` 已复核。未执行 `bun run verify`,因为本轮按 `check:impact` 选择的最小完整门禁交付,未声明 PR-ready/push-ready。 diff --git a/tests/manual/v0.4.10-to-head-ui-functional-checklist.md b/tests/manual/v0.4.10-to-head-ui-functional-checklist.md new file mode 100644 index 00000000..e9b7f45f --- /dev/null +++ b/tests/manual/v0.4.10-to-head-ui-functional-checklist.md @@ -0,0 +1,158 @@ +# v0.4.10 之后的人工界面功能测试 Checklist + +> 分析范围:`v0.4.10..91829e91`(2026-07-20 至 2026-07-21) +> +> 基线:`v0.4.10`;候选:`91829e91`;共 11 个非合并提交 +> +> 差异规模:233 files,+22,673 / -1,317 +> +> 本清单只覆盖该范围新增或修复的用户可观察行为,不代替 v0.4.10 全量回归。 + +## 1. 测试记录 + +| 项目 | 填写内容 | +|---|---| +| 测试人 / 日期 | Codex + Computer Use / 2026-07-21 | +| 安装包版本 / commit | `91829e91a41d0c4dcba2680e82f889ed7220e332` | +| 操作系统 / 架构 | Windows 11 / x64 | +| 安装方式 | 隔离开发构建;缓存 Electron 42.3(声明的 42.4 本地二进制不可用) | +| 测试 Provider / 模型 | 真实 DeepSeek / `deepseek-v4-pro`(179 ms);真实 MiniMax / `MiniMax-M3`(1,349 ms);均已取得真实回复 | +| 测试配置目录 | `artifacts/manual-ui-91829e91/config`;独立 Electron user-data-dir | +| 总体结论 | executed with recorded limits:DeepSeek、MiniMax 真实编程任务、附件恢复、多模态、SubAgent、Task 并发和 Windows 长空闲恢复主链路已通过;BUG-001~BUG-011 均已修复并取得聚焦或真实 UI 证据。最新完整质量门禁全部通过,但仍有宠物本体拖动/多显示器、代理/PAC、IM/H5 故障注入、CLI/TUI 等未执行或仅部分执行项,不能表述为“所有功能无条件通过” | + +结果标记约定:完成每项后在复选框后追加 `passed`、`failed`、`blocked` 或 `skipped`,并附截图、录屏、日志位置或缺陷编号。`blocked` 与 `skipped` 不计为通过。 + +## 2. 提交分析与覆盖映射 + +| 提交 | 更新类型 | 用户可观察变化 | 对应用例 | +|---|---|---|---| +| `36560c09` | 功能 | 新增可交互桌面宠物、任务状态面板、自定义宠物导入 | PET-01~PET-10 | +| `71dc2fd4` | 修复 | Kimi Code 预设升级到 K3、API Key 鉴权和 262K 上下文 | PRO-02~PRO-03 | +| `8995f56d` | 修复 | Provider 返回空/零 usage 时仍从会话记录估算上下文 | CTX-01 | +| `bf128cb9` | 修复 | 服务端回放带附件的用户消息时不再重复显示 | ATT-01~ATT-02 | +| `6b725a0b` | 修复 | 运行中的 SubAgent 可打开详情并持续显示新记录 | SUB-01~SUB-03 | +| `7d272e0f` | 修复 | 拒绝工具权限后当前轮正确中断并回到可输入状态 | DENY-01~DENY-02 | +| `ba8917f1` | 修复 | Provider 创建入口不再依赖运行时 presets 请求 | PRO-01、PRO-03 | +| `727bcea0` | 修复 | Windows Renderer、终端、代理及 IM WebSocket 生命周期恢复 | WIN-01~WIN-05 | +| `1270ced4` | 修复 | Task V2 工具常驻、任务读写串行、桌面任务状态抗乱序/抖动 | TASK-01~TASK-03 | +| `7f6fdada` | 功能 | 桌面 Agent CRUD、作用域、逐 Agent 模型与 effort、热重载,以及 CLI/TUI Agent effort 与编辑字段保真 | AGT-01~AGT-08、CLI-01~CLI-03 | +| `91829e91` | 修复 | 桌面会话恢复时保留文件/目录/PDF 内容,同时过滤终端反馈分享内容 | ATT-03~ATT-05 | + +## 3. 测试准备与安全边界 + +- [x] 使用专用测试账号、临时工作区和独立 `CLAUDE_CONFIG_DIR`,不得读取或改写开发者真实的 `~/.claude`、凭据、会话及 Agent 定义。 — passed:配置、会话与 Electron user-data 均在 `artifacts/manual-ui-91829e91/`。 +- [x] 准备一个可安全写入的 Git 测试项目,包含一份带唯一 canary 文本的小文件、一个目录和一个小型 PDF。 — passed:文本、目录和 PDF canary 已创建;PDF 已渲染目检。 +- [ ] 准备透明背景 PNG/WebP(32~4096 px、8 MB 内)以及一份不合规图片;动画图集测试另准备 1536×2288、8 列×11 行的 v2 PNG/WebP。 +- [x] 准备至少两个测试 Provider;Kimi 实际请求和任何 live model 测试仅在维护者明确授权额度后执行。 — passed:隔离配置中 DeepSeek 与 MiniMax 均完成真实连接测试;维护者已明确授权额度。 +- [ ] Windows 故障注入只对专用测试构建执行;提前保存工作并记录主进程、Renderer、Sidecar 的 PID。 +- [ ] 若测试代理/PAC,使用受控代理和测试地址;测试结束后恢复系统代理设置。 + +## 4. P0 核心流程 + +### 桌面宠物 + +- [x] **PET-01|入口、开关与持久化** — passed:确认 4 个内置宠物和独立透明宠物窗口;完整结束隔离 Electron/Sidecar 并用新构建重启后,显示开关、当前内置宠物、192px 大小和动画偏好均保持。当前显示的 Dada 机器人来自仓库内置 `dada-code` 资源,不是缺图 fallback;另一台电脑自行导入的猫/狗包属于 `${CLAUDE_CONFIG_DIR}/cc-haha/pets` 本地用户状态,不会随 Git 自动同步。 +- [ ] **PET-02|窗口交互与位置恢复**:分别验证短按宠物触发挥手、悬停/移动鼠标触发视线或动作、拖动宠物移动窗口;拖动不能误触单击动作,透明非宠物区域应允许点击下层窗口。将宠物拖到屏幕边缘和第二显示器后重启,位置应恢复且保持在可见工作区;拔除显示器或改变分辨率后窗口被钳制回可见区域。 — partial:BUG-010 修复后 Win32 `topmost=True`;BUG-011 修复后 Computer Use 已能真实鼠标命中任务圆点和关闭按钮,并完成两轮展开/关闭。宠物本体短按、悬停、拖动、透明区透传和多显示器恢复仍未完成。 +- [ ] **PET-03|任务状态与导航**:同时制造“工作中、等待用户处理、失败/需关注、等待审阅”状态,确认宠物动作/提示匹配且优先级为“等待处理 → 失败 → 等待审阅 → 工作中”;有多个非空闲会话时展开面板最多列出最近 9 个任务,点击整行聚焦主窗口及对应会话。 +- [ ] **PET-04|任务卡隐藏与关闭宠物**:默认不展示任务卡;有活跃任务时只显示宠物右上角数字圆点,点击圆点展开任务卡,点击下箭头隐藏卡片且不能改变任务状态。右键菜单关闭宠物后设置页开关立即同步为关闭,正在执行的任务不能被停止。 — partial:真实 Windows Electron + DeepSeek 编程任务中,Computer Use 已完成两轮“数字圆点展开 → 下箭头隐藏 → 数字圆点恢复”,最终 `showTaskPanel: false` 已落盘;隐藏卡片后任务继续执行 Bun 测试并返回 `2 pass / 0 fail`、`PET_CLOSE_SMOKE_OK`。右键关闭整个宠物及设置页同步本轮未复测。 + +### 附件与会话连续性 + +- [x] **ATT-01|图片消息回放去重**:发送“检查这张截图”并附带本地图片,等待服务端回放/重连;界面始终只有一个用户消息气泡和一个附件预览,不显示 `[Image source: ...]` 或内部绝对路径文本。 — passed:在首轮即选用 MiniMax-M3 的全新会话中只附加一张 `canary.png`,UI 仅出现一个用户气泡和一个附件,真实模型准确返回第二行标识 `PDF_CANARY_91829E91`。trace 显示 Anthropic `/v1/messages` 请求只有 1 个 image block、HTTP 200,且请求/响应均无 `[Unsupported Image]`。此前失败来自复用曾由 DeepSeek 生成 `[Unsupported Image]` 的跨 Provider 历史且同图被重复附加,不是 MiniMax 多模态接口限制,详见 PROVIDER-001。 +- [x] **ATT-02|多文件物化路径去重**:一次发送两个仅含 data 的不同类型文件,确认上传后仍只有一个用户气泡、两个名称正确的附件 chip;UUID 前缀的服务端上传路径不能出现在正文。随后发送同提示但不同附件,不能被误判为重复消息。 — passed:在全新 MiniMax-M3 会话中通过系统文件选择器一次选择 `canary.txt` 与 `resume-secret.txt`;UI 只有一个用户气泡和两个名称正确的附件 chip,正文未暴露物化路径,真实模型不调用工具即准确返回两个文件各自的 canary。 +- [x] **ATT-03|完全退出后恢复附件上下文**:在桌面端发送带唯一 canary 的文件,等待回复和持久化后完全退出应用;重启恢复同一会话,不重新附加文件,追问 canary。模型应能依据原附件内容回答,而不只是看到 `@路径`。 — passed:发送 `resume-secret.txt` 后完整退出并重启 Electron/Sidecar,恢复同一会话且不重新附加;真实模型仍回答 `HARBOR-PLUM-7319`,附件 chip 与用户消息未重复。 +- [x] **ATT-04|目录与 PDF 恢复**:分别用目录和 PDF 重复 ATT-03;历史中附件引用应正常显示,重启后的追问仍能使用原内容,且用户消息不重复。 — passed:完整退出并重启后,真实模型从已恢复的 PDF 回答 `PDF_CANARY_91829E91`,从目录回答 `FOLDER_CANARY_91829E91`;历史附件结构保持且用户消息未重复。 + +### SubAgent、权限拒绝与 Task + +- [x] **SUB-01|运行中详情**:启动一个持续至少 10 秒且会调用工具的 SubAgent,立即从活动面板点击运行项。详情标签应在任务完成前打开,显示“运行中”、Agent/来源/任务信息、提示词和已产生的运行记录。 — passed:真实 MiniMax 编程会话启动 SubAgent 后,在运行中从 Activity 打开详情;页面显示 prompt、source、agent、task、token 和已产生的 Read/Bash 记录。首次因隔离项目尚无 HEAD 导致 worktree 启动失败,用户选择不使用 worktree 后重新运行成功。 +- [x] **SUB-02|实时刷新与完成**:保持详情页打开,确认新增文本和工具调用约每 2 秒出现;task id 晚到时页面自动接入实时记录。完成后状态、结果、更新时间和可用的 token usage 正确,且不需要关闭重开。 — passed:详情保持打开直至完成,后续 Read/Bash 与结果可见;完成态显示完整 transcript、报告及 11,473 tokens,点击刷新后约 2 秒重新载入且无需关闭标签。 +- [x] **DENY-01|拒绝普通工具** — passed:真实 DeepSeek 请求 Write `denied.txt`,Computer Use 点击 Deny;工具卡显示 `User denied via UI error`,Shell 确认目标文件不存在,输入框恢复可用。 +- [x] **DENY-02|拒绝后继续聊天** — passed:拒绝后立即发送无工具追问,真实模型返回 `CHAT_CONTINUES`;没有残留 pending 权限、持续运行态或缺少 tool result 错误。计划确认拒绝仍待其他用例覆盖。 +- [x] **TASK-01|跨 Provider 完整生命周期与并发读写**:分别在至少两个 Provider 下让模型创建、读取、列出、更新并完成 Task V2;另让真实 Provider 在同一 assistant turn 并发发出两个 create/update,再紧随 get/list,确认写操作串行且读取不会看到中间态或丢失态。工具无需 ToolSearch 即可调用,任务栏状态与工具结果一致,消息结束时未完成的工具状态会再刷新一次。 — passed:真实 MiniMax-M3 与 DeepSeek 编程会话均调用 TaskCreate/List/Get/Update,分别将 3 个任务更新为 completed;任务栏同步显示 3 项绿色完成。两条流程均执行真实 `bun test`,各为 2 passed / 0 failed,最终分别返回 `REAL_TASK_AGENT_FLOW_OK` 与 `DEEPSEEK_TASK_V2_OK`。随后在修复版 Windows sidecar 上,真实 MiniMax 在同一轮并发执行 2 个 TaskCreate、TaskList + 2 个 TaskGet、并发 2 个 TaskUpdate 和最终 TaskList;两项均完整且为绿色 completed,返回 `TASK_PARALLEL_IDLE0_OK`。 +- [ ] **TASK-02|切换会话与完成后收起**:任务运行时切换到另一会话,上一会话任务应立即消失且不串入新会话;回到原会话状态恢复。关闭已完成任务列表后,后续轮询不能让它再次弹出。 + +### Agent 管理及逐 Agent 配置 + +- [ ] **AGT-01|浏览与来源优先级**:打开“设置 → Agents”,确认总数、生效数和来源分组正确;创建同名用户级与项目级 Agent,二者均可见,项目级显示为生效、用户级显示被项目来源覆盖。对可用来源抽查完整优先级:policy → CLI flag → project → user → plugin → built-in。 +- [x] **AGT-02|用户级创建**:创建名称含下划线的用户 Agent,填写描述、system prompt、模型、effort、工具模式和颜色;保存后自动选中刷新后的定义,详情值正确,文件写入隔离测试配置的 `agents` 目录。 — passed:在隔离配置创建 `configured_probe`,设置 fable、high、Read/Grep/Bash 与紫色;保存后自动进入详情且文件只写入隔离 `agents` 目录,随后从 UI 删除。 +- [ ] **AGT-03|项目级创建**:没有活动项目时项目范围必须禁用;打开项目会话后创建项目 Agent,目标路径显示当前项目,文件只写入该项目 `.claude/agents/`,其他项目不可见或不生效。 +- [ ] **AGT-04|模型、effort 与工具模式**:逐项确认模型包含继承、fable、opus、sonnet、haiku、自定义模型 ID;effort 包含继承、low、medium、high、xhigh、max;工具支持全部继承、禁用全部、自定义列表。缺少自定义模型 ID 或自定义工具时不能保存。 — partial:已真实保存并回读 fable、high、自定义 Read/Grep/Bash;其余模型/effort 枚举和缺失自定义值拦截尚未逐项执行。 +- [ ] **AGT-05|热重载并执行**:在活动会话中保存 Agent 后立即 spawn,不重启应用;新定义应可被发现并完成任务。修改 system prompt、模型或 effort 后再次 spawn,下一次运行使用新定义;从真实 Provider trace/运行详情核验 definition 的 model 与 effort 已实际应用,而不只核对表单值。 +- [ ] **AGT-06|编辑回继承与字段保真**:把模型、effort、颜色和工具改回继承/默认;重新打开详情确认对应配置显示为继承,frontmatter 中相应字段被删除。编辑描述时不得破坏未知高级 frontmatter、空 tools、带括号/逗号的工具名或原 system prompt。 +- [ ] **AGT-07|只读与删除**:内置、插件、托管策略、CLI 参数等来源只允许查看且没有可执行的编辑/删除;用户级和项目级可删除,确认弹窗展示准确目标,删除后列表刷新且文件消失。 — partial:内置 Agent 详情确认只读;用户级 `configured_probe` 已通过 UI 删除并从列表消失。插件/托管策略/CLI 来源及项目级删除尚未覆盖。 + +## 5. P1 功能与边界流程 + +### 桌面宠物高级用例 + +- [ ] **PET-05|外观与无动画**:将大小从 96 调到 192 px,悬浮窗实时反映且不裁切;关闭“播放动画”后内置与自定义宠物均静止但仍可点击、拖动和导航任务。 +- [x] **PET-06|单图创建轻动画宠物**:选择“添加宠物 → 用一张图片制作轻动画”,填写合法 ID、名称和描述,导入合规 PNG/WebP;创建后出现在“你的宠物”、自动选中并可显示。确认整个过程不调用当前聊天模型。 — passed:在隔离 Electron 中用仓库真实 `icon.png`(46,061 bytes,SHA-256 `26D6DCC091FB0971ACB78A3A42526F55CCDD58E812C2D15EE09458CFE0E7C2C7`)创建 `manual-canary`;列表立即出现并自动选中,隔离配置生成 `pet.json` + `pet.png`,manifest 为 single-image v1 / `soft-spring-v1`,会话时间戳及聊天内容未变化。 +- [ ] **PET-07|专业图集导入**:导入合法 1536×2288、8×11 v2 图集;确认逐帧动作、左右跑动、失败、等待、工作、复核及视线方向没有错格、漂移或透明背景异常。 +- [ ] **PET-08|输入校验与损坏包**:非法 ID、超 8 MB、尺寸越界、错误图集尺寸/格式均应在对话框中给出错误且不创建半成品。向自定义目录放入无效包后刷新,应跳过并显示无效数量;“打开文件夹”打开隔离配置下的正确目录。 — partial:非法下划线 ID 时提交按钮保持 disabled;选择真实损坏的 79-byte `.png` 后对话框保留并显示 `The pet image header is invalid`,未生成半成品。超限、错误图集、损坏包刷新和打开目录仍待执行。 +- [ ] **PET-09|非桌面能力边界**:在浏览器/H5 打开同一设置页,宠物启停、创建和打开本地目录等桌面专属操作应禁用或不可用,不应调用 Electron IPC、白屏或影响桌面端已保存偏好。 +- [ ] **PET-10|加载/保存/原生调用失败恢复**:让 preferences 或自定义宠物目录首次加载失败,页面应保留已有内容并提供 Retry;保存失败时开关和选择应回滚;原生 show/hide/context-menu 失败时宠物窗口与设置开关恢复到一致状态且显示可重试错误。 + +### Provider 与上下文 + +- [x] **PRO-01|内置 presets 与创建入口**:首次进入 Provider 设置时“添加 Provider”立即可用,弹窗展示随桌面包内置的预设;确认页面不需要先成功请求 `/api/providers/presets` 才能打开创建界面。 — passed:Computer Use 反复打开成功,DeepSeek、Zhipu GLM、Kimi、MiniMax、LM Studio、Ollama、Custom、接口AI、胜算云、TeamoRouter 均可见。 +- [ ] **PRO-02|Kimi K3 预设**:选择 Kimi,确认 Base URL 为 `https://api.kimi.com/coding/`,主模型及 Haiku/Sonnet/Opus 均为 `k3`,鉴权为 API Key,官网/密钥入口指向 Kimi Code,上下文上限识别为 262,144,并可选择到 `max` effort。已保存的旧 Kimi URL、模型和 auth token 配置在编辑时不得被静默覆盖。 — partial:Computer Use 确认新建预设的 Base URL、API Key 鉴权、四个 `k3` 模型、`k3: 262,144`,以及 capability preview 包含 `effort,max_effort`;旧配置保真和密钥官网跳转仍待执行。 +- [ ] **PRO-03|旧/未知 preset 编辑回退**:打开一个 presetId 已不存在或 presets 加载异常的旧 Provider,编辑表单仍应显示已有 Base URL、模型和鉴权值,不白屏、不丢配置;保存失败与“弹窗可打开”分别记录。 +- [ ] **CTX-01|空/零 usage 的上下文估算**:用真实且确实返回 `{}` 或全零 usage 的 Provider/现场 transcript 建立有明显长度的会话,关闭并恢复;上下文指示器不得空白或归零,应按 transcript 和实际运行模型窗口给出合理非零估算。继续对话后估算应单调合理变化,不能错误显示已满。找不到这种真实 Provider 证据时标记 blocked,不能用 mock 结果冒充人工通过。 + +### SubAgent 与任务异常边界 + +- [ ] **SUB-03|错误、切换与空记录**:运行中详情刷新临时失败时标签保持打开、已有内容保留并可重试;快速切换两个 SubAgent 时旧响应不能覆盖当前页;确实无本地记录时显示明确空状态而非崩溃。 +- [ ] **TASK-03|轮询抖动与乱序**:在测试代理中延迟旧请求并让新请求先返回,界面不得被旧状态回滚;临时任务轮询失败时保留最后已知任务且不闪空,首次进入新会话请求失败则保持空列表而非带入旧任务。 + +### Agent 管理边界 + +- [ ] **AGT-08|校验、错误与非阻塞警告**:名称超过 64 位或格式非法、描述为空、新建时 system prompt 为空均应前端拦截。模拟保存失败时表单保持打开并显示错误;模拟定义已写入但热重载/刷新失败时显示非阻塞警告和重试,已保存文件不得回滚。 + +## 6. P1 Windows / Electron 稳定性专项 + +- [x] **WIN-01|Renderer 异常退出自动恢复**:在 Windows 专用测试构建中仅终止 Renderer 子进程。主窗口应自动 reload 一次并恢复可操作,不应长期白屏、退出主进程或重复 reload;会话标签、设置和历史从持久状态恢复。隔离配置的 `cc-haha/diagnostics/electron-host.log` 应记录 process-gone、recovery-started 和 recovery-loaded。 — passed:关闭宠物窗口后确认隔离 Electron 仅有一个主 Renderer(PID 40812),只终止该子进程;3 秒内新 Renderer(PID 5696)启动,Settings/Pets 页面、两个会话标签、Huhu/192px 与关闭状态均恢复且可操作。日志依次记录 `process-gone`、`recovery-started`、`recovery-loaded`。 +- [ ] **WIN-02|短暂与持续无响应**:制造短于 10 秒的 Renderer 卡顿后恢复,应用不应误 reload;再制造持续超过约 10 秒的无响应,应只自动恢复一次。若再次故障,显示“界面恢复失败 / Interface Recovery Failed”双语错误框并留下诊断记录。 +- [ ] **WIN-03|终端与窗口销毁竞态**:让内置终端持续输出,同时关闭终端标签、reload Renderer、关闭主窗口各一次;不得出现 `Object has been destroyed`、主进程崩溃、幽灵输出或无法再次打开终端。 +- [ ] **WIN-04|系统代理/PAC 波动**:启用受控系统代理或 PAC,聊天请求建立期间切换节点、断开 CONNECT、短暂断网再恢复;应用不得弹出主进程 JavaScript 异常,恢复网络后无需重启即可再次请求,代理认证失败不得静默回退 DIRECT。 +- [ ] **WIN-05|IM WebSocket 握手中销毁**:启动 IM/H5 连接后,在 WebSocket 尚处于 CONNECTING 时切换会话、重置或退出适配器;不得产生未处理 `error`、主进程退出或残留连接,重连后新会话可正常收发。 +- [x] **WIN-06|Context 超时不能拖垮本地 REST** — passed:Chromium netlog 将故障精确定位为复用已空闲约 104 秒、但 Bun 已按 60 秒 `idleTimeout` 停止处理的 HTTP/1.1 socket;`GET /api/tasks/lists/` 写入后等待响应头 120,011 ms,turn-checkpoints/workspace-status 同期超时。改为 `idleTimeout: 0` 后重建真实 Windows sidecar,并合并同一会话任务轮询;应用空闲 85 秒后执行 MiniMax 并发 Task 全流程,task-list 请求最慢 235 ms,所有本地 API 最慢 578 ms。运行 137 秒后仍为 0 `CloseWait` / 0 `FinWait2`、无新增 timeout。再用项目声明版本的官方 Bun 1.3.14 Windows binary 编译 sidecar,空闲 106 秒后真实 MiniMax 返回 `IDLE0_BUN1314_OK`,运行 131 秒仍为 0 / 0 且无新增 timeout,详见 BUG-009。 + +## 7. P2 可访问性、隐私与兼容性 + +- [ ] **A11Y-01|Reduced Motion**:系统开启 reduced-motion 后,内置和自定义宠物都不播放动画;运行中的 SubAgent 状态仍可通过文字/非动画标记识别。 +- [ ] **I18N-01|新增界面语言**:依次切换简中、繁中、英文、日文、韩文,抽查宠物、Agent 管理和 SubAgent 详情;不得显示原始 i18n key、截断关键按钮或出现不可读混排。 +- [ ] **ATT-05|终端 FeedbackSurvey 转录分享隐私**:从 CLI/Ink 的 FeedbackSurvey 入口在隔离环境中提交包含附件的反馈分享,检查测试接收端或抓包证据;结构化消息和原始 JSONL 均不得包含 `type: attachment` 记录、附件正文或本地敏感路径,普通对话和 SubAgent 信息仍可提交。此项不是桌面 Settings/聊天反馈入口。 + +### CLI / TUI Agent 行为 + +- [ ] **CLI-01|effort 参数与 model-info**:在终端真实运行 `--effort xhigh`、`--effort max` 和合法整数,并验证非法值被明确拒绝;headless/model-info 应只展示当前模型实际支持的 effort levels。 +- [ ] **CLI-02|Agent effort 启动与恢复**:用 `--agent` 启动真实 Provider 编程任务,核验 Agent effort 生效;显式 CLI `--effort` 应按约定优先。退出并 resume 后从 trace 再次确认 model/effort 不丢失。 +- [ ] **CLI-03|TUI Agent 编辑字段保真**:在终端 AgentEditor 中编辑含 Markdown tools、system prompt 和未知高级 frontmatter 的 Agent;保存并重开后字段和值保持,不被简化或损坏。 + +## 8. 已知风险与待确认项 + +- [ ] **RISK-01|Agent effort 优先级契约**:当前实现及测试允许“标记为 request-scoped 的 Agent effort”覆盖 `CLAUDE_CODE_EFFORT_LEVEL`,但 `docs/agent/01-usage-guide.md` 写的是环境变量优先。使用同一支持 effort 的模型设置会话 `high`、Agent `xhigh`,记录实际请求值;发布前由维护者确认期望契约并统一代码、测试和中英文文档。本清单不把存在冲突的任一结果直接判为通过。 +- [ ] **RISK-02|live-provider 证据**:配置值检查、mock 或本地契约测试不等同真实 Kimi/Provider 请求。没有额度授权时标记 `blocked` 或 `skipped`,不得写成 passed。 +- [ ] **RISK-03|平台证据**:Renderer、系统代理、终端 native 模块的自动化结果不等同 Windows 实机;没有 Windows 安装包实测时 WIN 项不得写成 passed。 + +## 9. 通常不需要普通 UI 人工执行的内部变化 + +- CI/release workflow、Electron 输出目录 guard、Pet atlas 组装脚本和构建清理。 +- SDK schema、模型 alias、Provider capability 环境变量等内部协议传播;Task `alwaysLoad`/并发标记和 CLI effort 的用户可见结果已分别纳入 TASK-01、CLI-01~CLI-03。 +- WebSocket teardown error sink、代理 socket 清理和销毁后 terminal IPC 防护已合并到 WIN-03~WIN-05,不在日常跨平台冒烟中重复拆项。 +- 自动化测试本身不是人工通过证据;人工结论只记录本清单实际执行结果。 + +## 10. 完成判定 + +- [ ] 所有适用于发布平台的 P0 项为 `passed`,没有未解释的 failed/blocked。 +- [ ] P1/P2 的 failed、blocked、skipped 均有缺陷编号、风险接受人或明确后续计划。 +- [ ] 每个提交至少有一条人工证据,或在第 9 节明确归为内部变化。 +- [ ] 回归后重新核对 commit;若 HEAD 已超过 `91829e91`,先补做新增提交分析再沿用本清单。 + +## 11. 本轮执行收口 + +- [x] 回归结束时 HEAD 仍为 `91829e91`,提交分析范围未漂移。 +- [x] 真实 Provider 证据来自 DeepSeek 与 MiniMax;图片、多附件、恢复、权限拒绝、SubAgent、Task V2 与真实代码测试均未使用 mock。 +- [x] 完整重跑 `check:desktop`、`check:server`、`check:provider-contract`、`check:chat-contract`、`check:adapters`、`check:native`、`check:coverage`,均通过;`check:impact` 仅因 CLI core 变更需要 PR 标签和维护者批准而处于 policy blocked,不是测试失败。 +- [ ] 发布签字仍需处理本清单未勾选/partial 项;尤其 PET-02~05/07/09/10、WIN-02~05、CLI-01~03 和 H5 长连接资源风险不能从本轮证据外推为 passed。