From 35f43e82893588078fcadb9639768f4d331975e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 2 Jul 2026 22:11:43 +0800 Subject: [PATCH] fix: resolve post-release semantic conflicts Fix cross-issue regressions found during post-0.4.4 merge review:\n\n- preserve permission mode across clear and empty-session replacement flows\n- keep provider effort passthrough and context-window estimates aligned with runtime metadata\n- invalidate recent project caches and trace message signatures when sessions change\n- recognize Windows ARM64 unpacked package-smoke output\n\nTested: bun test scripts/quality-gate/package-smoke/index.test.ts scripts/quality-gate/runner.test.ts\nTested: bun run check:desktop\nTested: bun run check:server\nConfidence: high\nScope-risk: moderate --- .../src/components/chat/ChatInput.test.tsx | 62 ++++ desktop/src/components/chat/ChatInput.tsx | 14 +- .../src/components/shared/DirectoryPicker.tsx | 17 +- desktop/src/lib/recentProjectsCache.test.ts | 48 +++ desktop/src/lib/recentProjectsCache.ts | 20 ++ desktop/src/pages/TraceSession.test.tsx | 19 ++ desktop/src/pages/TraceSession.tsx | 1 + desktop/src/stores/sessionStore.test.ts | 76 ++++- desktop/src/stores/sessionStore.ts | 7 + desktop/src/types/trace.ts | 1 + scripts/quality-gate/modes.ts | 8 + scripts/quality-gate/package-smoke/current.ts | 14 +- .../quality-gate/package-smoke/index.test.ts | 47 ++- scripts/quality-gate/package-smoke/index.ts | 31 +- scripts/quality-gate/runner.test.ts | 10 +- src/server/__tests__/conversations.test.ts | 299 +++++++++++++++++- src/server/__tests__/sessions.test.ts | 160 ++++++++++ src/server/api/sessions.ts | 10 +- src/server/services/sessionService.ts | 233 +++++++++++++- src/server/ws/handler.ts | 5 +- src/services/api/claude.ts | 45 ++- src/services/api/claudeEffort.test.ts | 66 ++++ 22 files changed, 1137 insertions(+), 56 deletions(-) create mode 100644 desktop/src/lib/recentProjectsCache.test.ts create mode 100644 desktop/src/lib/recentProjectsCache.ts diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 471c6d1c..e7ba978d 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -823,6 +823,68 @@ describe('ChatInput file mentions', () => { }) }) + it('preserves explicit permission mode when replacing an empty session for branch launch', async () => { + mocks.create.mockResolvedValueOnce({ sessionId: 'created-permission', workDir: '/repo' }) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + permissionMode: 'acceptEdits', + }], + activeSessionId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ })) + fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ })) + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { target: { value: 'run with preserved permissions', selectionStart: 30 } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => { + expect(mocks.create).toHaveBeenCalledWith({ + workDir: '/repo', + repository: { branch: 'feature/a', worktree: false }, + permissionMode: 'acceptEdits', + }) + }) + expect(mocks.wsSend).toHaveBeenCalledWith('created-permission', { + type: 'user_message', + content: 'run with preserved permissions', + attachments: [], + }) + }) + it('starts an empty active session on the selected branch inside an isolated worktree', async () => { mocks.create.mockResolvedValueOnce({ sessionId: 'created-worktree', diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 36d36c78..e88394b6 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -43,6 +43,7 @@ import { } from '../../lib/composerAttachments' import { useComposerFileDrop } from './useComposerFileDrop' import { shouldSubmitOnEnter } from './sendShortcut' +import type { PermissionMode } from '../../types/settings' type GitInfo = SessionGitInfo @@ -565,12 +566,21 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ) => { if (!activeTabId) return null const oldId = activeTabId - const { createSession, deleteSession } = useSessionStore.getState() + const sessionStore = useSessionStore.getState() + const { createSession, deleteSession } = sessionStore const { replaceTabSession } = useTabStore.getState() const { disconnectSession, connectToSession, setComposerDraft } = useChatStore.getState() + const permissionMode = sessionStore.sessions.find((session) => session.id === oldId) + ?.permissionMode as PermissionMode | undefined + const createOptions = repository || permissionMode + ? { + ...(repository ? { repository } : {}), + ...(permissionMode ? { permissionMode } : {}), + } + : undefined const newId = await createSession( workDir || undefined, - repository ? { repository } : undefined, + createOptions, ) if (inputRef.current.length > 0 || attachmentsRef.current.length > 0) { setComposerDraft(newId, { diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index a1c8a9da..44442cec 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -5,6 +5,11 @@ import { filesystemApi } from '../../api/filesystem' import { useTranslation } from '../../i18n' import { useMobileViewport } from '../../hooks/useMobileViewport' import { getDesktopHost } from '../../lib/desktopHost' +import { + getCachedRecentProjects, + invalidateRecentProjectsCache, + setCachedRecentProjects, +} from '../../lib/recentProjectsCache' import { MobileBottomSheet } from './MobileBottomSheet' type Props = { @@ -16,10 +21,6 @@ type Props = { type DirEntry = { name: string; path: string; isDirectory: boolean } -// Module-level cache for recent projects (shared across instances, survives re-renders) -let cachedProjects: RecentProject[] | null = null -let cacheTimestamp = 0 -const CACHE_TTL = 30_000 // 30s const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' const DROPDOWN_WIDTH = 400 const DROPDOWN_VIEWPORT_MARGIN = 12 @@ -98,15 +99,15 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec useEffect(() => { if (!isOpen || mode !== 'recent') return // Use cache if fresh - if (cachedProjects && Date.now() - cacheTimestamp < CACHE_TTL) { + const cachedProjects = getCachedRecentProjects() + if (cachedProjects) { setProjects(cachedProjects) return } setLoading(true) sessionsApi.getRecentProjects() .then(({ projects: p }) => { - cachedProjects = p - cacheTimestamp = Date.now() + setCachedRecentProjects(p) setProjects(p) }) .catch(() => setProjects([])) @@ -129,7 +130,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec setIsOpen(false) setMode('recent') // Invalidate cache so next open reflects the new selection - cachedProjects = null + invalidateRecentProjectsCache() } const handleChooseFolder = async () => { diff --git a/desktop/src/lib/recentProjectsCache.test.ts b/desktop/src/lib/recentProjectsCache.test.ts new file mode 100644 index 00000000..783368ff --- /dev/null +++ b/desktop/src/lib/recentProjectsCache.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RecentProject } from '../api/sessions' +import { + getCachedRecentProjects, + invalidateRecentProjectsCache, + setCachedRecentProjects, +} from './recentProjectsCache' + +function makeProject(path: string): RecentProject { + return { + projectPath: path, + realPath: path, + projectName: path.split('/').filter(Boolean).pop() || path, + repoName: null, + branch: null, + isGit: false, + modifiedAt: '2026-05-07T00:00:00.000Z', + sessionCount: 1, + } +} + +describe('recentProjectsCache', () => { + afterEach(() => { + vi.useRealTimers() + invalidateRecentProjectsCache() + }) + + it('returns fresh cached projects until they are invalidated or expire', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-07T00:00:00.000Z')) + const projects = [makeProject('/workspace/project')] + + setCachedRecentProjects(projects) + + expect(getCachedRecentProjects()).toBe(projects) + + vi.setSystemTime(new Date('2026-05-07T00:00:29.999Z')) + expect(getCachedRecentProjects()).toBe(projects) + + vi.setSystemTime(new Date('2026-05-07T00:00:30.000Z')) + expect(getCachedRecentProjects()).toBeNull() + + setCachedRecentProjects(projects) + invalidateRecentProjectsCache() + + expect(getCachedRecentProjects()).toBeNull() + }) +}) diff --git a/desktop/src/lib/recentProjectsCache.ts b/desktop/src/lib/recentProjectsCache.ts new file mode 100644 index 00000000..ebe5c6f5 --- /dev/null +++ b/desktop/src/lib/recentProjectsCache.ts @@ -0,0 +1,20 @@ +import type { RecentProject } from '../api/sessions' + +let cachedProjects: RecentProject[] | null = null +let cacheTimestamp = 0 +const CACHE_TTL = 30_000 + +export function getCachedRecentProjects(): RecentProject[] | null { + if (!cachedProjects || Date.now() - cacheTimestamp >= CACHE_TTL) return null + return cachedProjects +} + +export function setCachedRecentProjects(projects: RecentProject[]): void { + cachedProjects = projects + cacheTimestamp = Date.now() +} + +export function invalidateRecentProjectsCache(): void { + cachedProjects = null + cacheTimestamp = 0 +} diff --git a/desktop/src/pages/TraceSession.test.tsx b/desktop/src/pages/TraceSession.test.tsx index 30f0f533..d711c981 100644 --- a/desktop/src/pages/TraceSession.test.tsx +++ b/desktop/src/pages/TraceSession.test.tsx @@ -419,6 +419,25 @@ describe('TraceSession', () => { expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1) }) + it('refetches messages when only the trace message signature changes', async () => { + const pendingMessages = baseMessages.filter((message) => message.type !== 'tool_result') + vi.mocked(sessionsApi.getTrace) + .mockResolvedValueOnce({ ...baseTrace, messageSignature: '3:tool-use' }) + .mockResolvedValue({ ...baseTrace, messageSignature: '4:tool-result' }) + vi.mocked(sessionsApi.getMessages) + .mockResolvedValueOnce({ messages: pendingMessages }) + .mockResolvedValue({ messages: baseMessages }) + + await renderReady(20) + + const tree = within(screen.getByTestId('trace-tree')) + fireEvent.click(tree.getByText('Bash')) + expect(within(screen.getByTestId('trace-detail')).queryByText('file.txt')).not.toBeInTheDocument() + + await waitFor(() => expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)) + expect(within(screen.getByTestId('trace-detail')).getByText('file.txt')).toBeInTheDocument() + }) + it('applies poll updates when a call changes without changing row counts', async () => { const pendingTrace: TraceSessionData = { ...baseTrace, diff --git a/desktop/src/pages/TraceSession.tsx b/desktop/src/pages/TraceSession.tsx index 5fc758b3..96b3fd1b 100644 --- a/desktop/src/pages/TraceSession.tsx +++ b/desktop/src/pages/TraceSession.tsx @@ -458,6 +458,7 @@ function TraceEmpty() { function traceSnapshotSignature(trace: TraceSessionData): string { return JSON.stringify({ summary: trace.summary, + messageSignature: trace.messageSignature ?? null, calls: trace.calls.map((call) => ({ id: call.id, status: call.status, diff --git a/desktop/src/stores/sessionStore.test.ts b/desktop/src/stores/sessionStore.test.ts index 699e37ac..fdca971d 100644 --- a/desktop/src/stores/sessionStore.test.ts +++ b/desktop/src/stores/sessionStore.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { branchMock, createMock, listMock } = vi.hoisted(() => ({ +const { branchMock, createMock, deleteMock, batchDeleteMock, listMock, invalidateRecentProjectsCacheMock } = vi.hoisted(() => ({ branchMock: vi.fn(), createMock: vi.fn(), + deleteMock: vi.fn(), + batchDeleteMock: vi.fn(), listMock: vi.fn(), + invalidateRecentProjectsCacheMock: vi.fn(), })) vi.mock('../api/sessions', () => ({ @@ -11,11 +14,16 @@ vi.mock('../api/sessions', () => ({ branch: branchMock, create: createMock, list: listMock, - delete: vi.fn(), + delete: deleteMock, + batchDelete: batchDeleteMock, rename: vi.fn(), }, })) +vi.mock('../lib/recentProjectsCache', () => ({ + invalidateRecentProjectsCache: invalidateRecentProjectsCacheMock, +})) + import { useSessionStore } from './sessionStore' import { useSettingsStore } from './settingsStore' import { useTabStore } from './tabStore' @@ -52,7 +60,10 @@ describe('sessionStore', () => { beforeEach(() => { branchMock.mockReset() createMock.mockReset() + deleteMock.mockReset() + batchDeleteMock.mockReset() listMock.mockReset() + invalidateRecentProjectsCacheMock.mockReset() useSessionStore.setState({ ...initialState, sessions: [], @@ -87,6 +98,7 @@ describe('sessionStore', () => { workDir: 'D:/workspace/code/myself_code/cc-haha', workDirExists: true, }) + expect(invalidateRecentProjectsCacheMock).toHaveBeenCalledOnce() expect(createMock).toHaveBeenCalledWith({ workDir: 'D:/workspace/code/myself_code/cc-haha', }) @@ -295,6 +307,65 @@ describe('sessionStore', () => { expect(useSessionStore.getState().sessions[0]?.permissionMode).toBe('acceptEdits') }) + it('invalidates cached recent projects after deleting a session', async () => { + deleteMock.mockResolvedValue({ ok: true }) + useSessionStore.setState({ + sessions: [makeSession('session-delete-1', '2026-05-07T00:00:00.000Z')], + activeSessionId: 'session-delete-1', + }) + + await useSessionStore.getState().deleteSession('session-delete-1') + + expect(deleteMock).toHaveBeenCalledWith('session-delete-1') + expect(invalidateRecentProjectsCacheMock).toHaveBeenCalledOnce() + expect(useSessionStore.getState().sessions).toEqual([]) + expect(useSessionStore.getState().activeSessionId).toBeNull() + }) + + it('invalidates cached recent projects after successful batch deletion', async () => { + batchDeleteMock.mockResolvedValue({ + ok: true, + successes: ['session-delete-a'], + failures: [{ sessionId: 'session-delete-b', message: 'locked' }], + }) + useSessionStore.setState({ + sessions: [ + makeSession('session-delete-a', '2026-05-07T00:00:00.000Z'), + makeSession('session-delete-b', '2026-05-07T00:00:01.000Z'), + ], + activeSessionId: 'session-delete-b', + }) + + const result = await useSessionStore.getState().deleteSessions([ + 'session-delete-a', + 'session-delete-b', + 'session-delete-a', + ]) + + expect(batchDeleteMock).toHaveBeenCalledWith(['session-delete-a', 'session-delete-b']) + expect(result.successes).toEqual(['session-delete-a']) + expect(invalidateRecentProjectsCacheMock).toHaveBeenCalledOnce() + expect(useSessionStore.getState().sessions.map((session) => session.id)).toEqual(['session-delete-b']) + expect(useSessionStore.getState().activeSessionId).toBe('session-delete-b') + }) + + it('keeps cached recent projects when batch deletion has no successes', async () => { + batchDeleteMock.mockResolvedValue({ + ok: false, + successes: [], + failures: [{ sessionId: 'session-delete-b', message: 'locked' }], + }) + useSessionStore.setState({ + sessions: [makeSession('session-delete-b', '2026-05-07T00:00:01.000Z')], + activeSessionId: 'session-delete-b', + }) + + await useSessionStore.getState().deleteSessions(['session-delete-b']) + + expect(invalidateRecentProjectsCacheMock).not.toHaveBeenCalled() + expect(useSessionStore.getState().sessions.map((session) => session.id)).toEqual(['session-delete-b']) + }) + it('returns the branched session before the background refresh completes', async () => { branchMock.mockResolvedValue({ sessionId: 'session-branch-1', @@ -331,6 +402,7 @@ describe('sessionStore', () => { expect(branchMock).toHaveBeenCalledWith('session-source-1', { targetMessageId: 'transcript-message-1', }) + expect(invalidateRecentProjectsCacheMock).toHaveBeenCalledOnce() expect(useSessionStore.getState().activeSessionId).toBe('session-branch-1') expect(useSessionStore.getState().sessions[0]).toMatchObject({ id: 'session-branch-1', diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index 2728c57a..b6690e7f 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -11,6 +11,7 @@ import { useTabStore } from './tabStore' import type { SessionListItem } from '../types/session' import type { PermissionMode } from '../types/settings' import { isPlaceholderSessionTitle } from '../lib/sessionTitle' +import { invalidateRecentProjectsCache } from '../lib/recentProjectsCache' const SESSION_LIST_LIMIT = 400 @@ -86,6 +87,7 @@ export const useSessionStore = create((set, get) => ({ ...(options?.repository ? { repository: options.repository } : {}), ...(requestedPermissionMode ? { permissionMode: requestedPermissionMode } : {}), }) + invalidateRecentProjectsCache() const now = new Date().toISOString() const optimisticSession: SessionListItem = { id, @@ -116,6 +118,7 @@ export const useSessionStore = create((set, get) => ({ targetMessageId, ...(options?.title ? { title: options.title } : {}), }) + invalidateRecentProjectsCache() const sourceSession = get().sessions.find((session) => session.id === sourceSessionId) const now = new Date().toISOString() const optimisticSession: SessionListItem = { @@ -150,6 +153,7 @@ export const useSessionStore = create((set, get) => ({ deleteSession: async (id: string) => { await sessionsApi.delete(id) + invalidateRecentProjectsCache() useSessionRuntimeStore.getState().clearSelection(id) set((s) => ({ sessions: s.sessions.filter((session) => session.id !== id), @@ -161,6 +165,9 @@ export const useSessionStore = create((set, get) => ({ deleteSessions: async (ids: string[]) => { const sessionIds = [...new Set(ids)].filter(Boolean) const result = await sessionsApi.batchDelete(sessionIds) + if (result.successes.length > 0) { + invalidateRecentProjectsCache() + } for (const id of result.successes) { useSessionRuntimeStore.getState().clearSelection(id) } diff --git a/desktop/src/types/trace.ts b/desktop/src/types/trace.ts index d55b7150..60162f6f 100644 --- a/desktop/src/types/trace.ts +++ b/desktop/src/types/trace.ts @@ -82,6 +82,7 @@ export type TraceSessionSummary = { export type TraceSession = { sessionId: string + messageSignature?: string | null session?: { id: string title: string diff --git a/scripts/quality-gate/modes.ts b/scripts/quality-gate/modes.ts index cd74fbfd..11cf3901 100644 --- a/scripts/quality-gate/modes.ts +++ b/scripts/quality-gate/modes.ts @@ -8,6 +8,10 @@ export function currentPackageSmokePlatform(platform: NodeJS.Platform = process. return null } +export function currentPackageSmokeArch(arch: NodeJS.Architecture = process.arch) { + return arch === 'arm64' || arch === 'x64' ? arch : null +} + export function currentReleaseArtifactsDir( platform: NodeJS.Platform = process.platform, arch: NodeJS.Architecture = process.arch, @@ -20,6 +24,7 @@ export function currentReleaseArtifactsDir( export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTarget[] = []): LaneDefinition[] { const packageSmokePlatform = currentPackageSmokePlatform() + const packageSmokeArch = currentPackageSmokeArch() const releaseArtifactsDir = currentReleaseArtifactsDir() const lanes: LaneDefinition[] = [ { @@ -131,6 +136,9 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar if (packageSmokePlatform && releaseArtifactsDir) { const packageSmokeCommand = ['bun', 'run', 'test:package-smoke', '--platform', packageSmokePlatform, '--package-kind', 'release', '--artifacts-dir', releaseArtifactsDir] + if (packageSmokeArch) { + packageSmokeCommand.push('--arch', packageSmokeArch) + } if (packageSmokePlatform === 'macos') { packageSmokeCommand.push('--require-macos-gatekeeper') } diff --git a/scripts/quality-gate/package-smoke/current.ts b/scripts/quality-gate/package-smoke/current.ts index e34ae7e0..fdbcda30 100644 --- a/scripts/quality-gate/package-smoke/current.ts +++ b/scripts/quality-gate/package-smoke/current.ts @@ -9,6 +9,10 @@ export function currentPackageSmokePlatform(platform: NodeJS.Platform = process. return null } +export function currentPackageSmokeArch(arch: NodeJS.Architecture = process.arch) { + return arch === 'arm64' || arch === 'x64' ? arch : null +} + if (import.meta.main) { const platform = currentPackageSmokePlatform() if (!platform) { @@ -16,7 +20,7 @@ if (import.meta.main) { process.exit(0) } - const result = spawnSync('bun', [ + const args = [ 'run', 'test:package-smoke', '--platform', @@ -25,7 +29,13 @@ if (import.meta.main) { 'dir', '--artifacts-dir', 'desktop/build-artifacts/electron', - ], { + ] + const arch = currentPackageSmokeArch() + if (arch) { + args.push('--arch', arch) + } + + const result = spawnSync('bun', args, { stdio: 'inherit', }) process.exit(result.status ?? 1) diff --git a/scripts/quality-gate/package-smoke/index.test.ts b/scripts/quality-gate/package-smoke/index.test.ts index dad0d003..b451eef8 100644 --- a/scripts/quality-gate/package-smoke/index.test.ts +++ b/scripts/quality-gate/package-smoke/index.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { + currentPackageSmokeArch, currentPackageSmokePlatform, } from './current' import { @@ -58,6 +59,9 @@ describe('package smoke args', () => { expect(currentPackageSmokePlatform('win32')).toBe('windows') expect(currentPackageSmokePlatform('linux')).toBe('linux') expect(currentPackageSmokePlatform('freebsd')).toBeNull() + expect(currentPackageSmokeArch('arm64')).toBe('arm64') + expect(currentPackageSmokeArch('x64')).toBe('x64') + expect(currentPackageSmokeArch('ia32')).toBeNull() }) }) @@ -318,11 +322,11 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/Claude-Code-Haha-0.3.1-arm64.exe') writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/Claude-Code-Haha-0.3.1-arm64.exe.blockmap') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app-update.yml') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-pc-windows-msvc.exe') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-arm64/pty.node') writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/latest.yml', 'path: Claude-Code-Haha-0.3.1-arm64.exe\n') const report = await inspectPackagedArtifacts(rootDir, { @@ -343,11 +347,11 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/Claude-Code-Haha-0.3.1-arm64.exe') writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/Claude-Code-Haha-0.3.1-arm64.exe.blockmap') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app-update.yml') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') - writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/latest.yml', 'path: Claude-Code-Haha-0.3.1-arm64.exe\n') const report = await inspectPackagedArtifacts(rootDir, { @@ -396,6 +400,29 @@ describe('packaged artifact inspection', () => { expect(report.missingChecks.some((check) => check.label.includes('.exe installer'))).toBe(true) }) + test('does not treat win-arm64-unpacked executables as Windows release installers', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/Claude Code Haha.exe') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/windows-arm64/win-arm64-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-arm64/pty.node') + + const report = await inspectPackagedArtifacts(rootDir, { + platform: 'windows', + arch: 'arm64', + packageKind: 'release', + artifactsDir: 'desktop/build-artifacts/windows-arm64', + }) + + expect(report.passed).toBe(false) + expect(report.packagedArtifacts.some((artifact) => artifact.path.includes('win-arm64-unpacked'))).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('.exe installer'))).toBe(true) + }) + test('passes Linux checks against the canonical build script output directory', async () => { const rootDir = createRepoRoot() tempDirs.push(rootDir) diff --git a/scripts/quality-gate/package-smoke/index.ts b/scripts/quality-gate/package-smoke/index.ts index eb24befc..ce35cea9 100644 --- a/scripts/quality-gate/package-smoke/index.ts +++ b/scripts/quality-gate/package-smoke/index.ts @@ -573,9 +573,9 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) { const installers = findMatches(report.artifactsDir, (candidate) => { const normalized = normalizePath(candidate) - return normalized.endsWith('.exe') && !normalized.includes('/win-unpacked/') + return normalized.endsWith('.exe') && !isInsideWindowsUnpackedDir(normalized) }) - const unpackedDir = findMatches(report.artifactsDir, (candidate) => normalizePath(candidate).endsWith('/win-unpacked'), { directoriesOnly: true })[0] + const unpackedDir = findWindowsUnpackedDir(report.artifactsDir, report.arch) const electronDir = join(report.artifactsDir, 'electron') const updateMetadata = findMatches(report.artifactsDir, (candidate) => candidate.endsWith('latest.yml')) const releaseMode = report.packageKind === 'release' || (report.packageKind === 'auto' && (installers.length > 0 || updateMetadata.length > 0)) @@ -640,8 +640,8 @@ function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) { ) } else { report.missingChecks.push({ - label: 'Windows unpacked directory (win-unpacked) for static resource inspection', - path: toRelative(rootDir, join(electronDir, 'win-unpacked')), + label: 'Windows unpacked directory for static resource inspection', + path: toRelative(rootDir, join(electronDir, report.arch === 'arm64' ? 'win-arm64-unpacked' : 'win-unpacked')), }) } @@ -657,6 +657,29 @@ function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) { } } +function isWindowsUnpackedDirPath(candidate: string): boolean { + return /\/win(?:-[a-z0-9_]+)?-unpacked$/.test(normalizePath(candidate)) +} + +function isInsideWindowsUnpackedDir(candidate: string): boolean { + return /\/win(?:-[a-z0-9_]+)?-unpacked\//.test(normalizePath(candidate)) +} + +function findWindowsUnpackedDir(artifactsDir: string, arch?: 'x64' | 'arm64'): string | undefined { + const unpackedDirs = findMatches( + artifactsDir, + isWindowsUnpackedDirPath, + { directoriesOnly: true }, + ) + if (arch === 'arm64') { + return unpackedDirs.find((candidate) => normalizePath(candidate).endsWith('/win-arm64-unpacked')) ?? unpackedDirs[0] + } + if (arch === 'x64') { + return unpackedDirs.find((candidate) => normalizePath(candidate).endsWith('/win-unpacked')) ?? unpackedDirs[0] + } + return unpackedDirs[0] +} + function inspectLinuxArtifacts(rootDir: string, report: PackageSmokeReport) { const packagedArtifacts = findMatches( report.artifactsDir, diff --git a/scripts/quality-gate/runner.test.ts b/scripts/quality-gate/runner.test.ts index 9d923882..38685bf0 100644 --- a/scripts/quality-gate/runner.test.ts +++ b/scripts/quality-gate/runner.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { currentPackageSmokePlatform, currentReleaseArtifactsDir, lanesForMode } from './modes' +import { currentPackageSmokeArch, currentPackageSmokePlatform, currentReleaseArtifactsDir, lanesForMode } from './modes' import { renderJUnitReport, renderMarkdownReport } from './reporter' import { runQualityGate, runQualityGateLanes } from './runner' import type { LaneDefinition, QualityGateReport } from './types' @@ -39,6 +39,7 @@ describe('quality gate modes', () => { const laneDefinitions = lanesForMode('release') const lanes = laneDefinitions.map((lane) => lane.id) const packageSmokePlatform = currentPackageSmokePlatform() + const packageSmokeArch = currentPackageSmokeArch() expect(lanes).toContain('policy-checks') expect(lanes).toContain('desktop-checks') expect(lanes).toContain('server-checks') @@ -56,6 +57,10 @@ describe('quality gate modes', () => { expect(packageSmokeLane?.command).toContain('--package-kind') expect(packageSmokeLane?.command).toContain('release') expect(packageSmokeLane?.command).toContain('--artifacts-dir') + if (packageSmokeArch) { + expect(packageSmokeLane?.command).toContain('--arch') + expect(packageSmokeLane?.command).toContain(packageSmokeArch) + } if (packageSmokePlatform === 'macos') { expect(packageSmokeLane?.command).toContain('--require-macos-gatekeeper') } @@ -68,6 +73,9 @@ describe('quality gate modes', () => { expect(currentPackageSmokePlatform('win32')).toBe('windows') expect(currentPackageSmokePlatform('linux')).toBe('linux') expect(currentPackageSmokePlatform('freebsd')).toBeNull() + expect(currentPackageSmokeArch('arm64')).toBe('arm64') + expect(currentPackageSmokeArch('x64')).toBe('x64') + expect(currentPackageSmokeArch('ia32')).toBeNull() expect(currentReleaseArtifactsDir('darwin', 'arm64')).toBe('desktop/build-artifacts/macos-arm64') expect(currentReleaseArtifactsDir('darwin', 'x64')).toBe('desktop/build-artifacts/macos-x64') expect(currentReleaseArtifactsDir('win32', 'x64')).toBe('desktop/build-artifacts/windows-x64') diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 80ae07aa..5c0b1876 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -699,6 +699,197 @@ describe('ConversationService', () => { } }) + it('should prefer the persisted runtime model when provider responses use aliased model names', async () => { + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousNodeEnv = process.env.NODE_ENV + const previousModelContextWindows = process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-runtime-model-')) + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-runtime-model-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + process.env.NODE_ENV = 'development' + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + + try { + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'custom', + name: 'Aliased Runtime Provider', + apiKey: 'provider-key', + authStrategy: 'auth_token', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + models: { + main: 'provider-main', + haiku: 'provider-fast', + sonnet: 'provider-sonnet', + opus: 'provider-opus', + }, + modelContextWindows: { + 'provider-main': 200_000, + 'provider-fast': 64_000, + }, + }) + await providerService.activateProvider(provider.id) + + const svc = new SessionService() + const { sessionId } = await svc.createSession(workDir) + await svc.appendSessionMetadata(sessionId, { + workDir, + runtimeProviderId: provider.id, + runtimeModelId: 'provider-fast', + }) + const found = await svc.findSessionFile(sessionId) + expect(found).not.toBeNull() + + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-06-15T12:00:00.000Z', + cwd: workDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'provider-returned-fast-alias', + content: [{ type: 'text', text: 'hello' }], + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }, + }) + '\n') + + const contextEstimate = await svc.getTranscriptContextEstimate(sessionId) + const usage = await svc.getTranscriptUsage(sessionId) + + expect(contextEstimate?.model).toBe('provider-returned-fast-alias') + expect(contextEstimate?.rawMaxTokens).toBe(64_000) + expect(usage?.models[0]?.contextWindow).toBe(64_000) + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + if (previousModelContextWindows === undefined) { + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + } else { + process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS = previousModelContextWindows + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }) + await fs.rm(workDir, { recursive: true, force: true }) + } + }) + + it('should keep transcript usage context windows tied to runtime metadata order', async () => { + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousNodeEnv = process.env.NODE_ENV + const previousModelContextWindows = process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-runtime-switch-')) + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-runtime-switch-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + process.env.NODE_ENV = 'development' + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + + try { + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'custom', + name: 'Runtime Switch Provider', + apiKey: 'provider-key', + authStrategy: 'auth_token', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + models: { + main: 'provider-big', + haiku: 'provider-fast', + sonnet: 'provider-big', + opus: 'provider-big', + }, + modelContextWindows: { + 'provider-big': 1_000_000, + 'provider-fast': 64_000, + }, + }) + await providerService.activateProvider(provider.id) + + const svc = new SessionService() + const { sessionId, workDir: sessionWorkDir } = await svc.createSession(workDir) + await svc.appendSessionMetadata(sessionId, { + workDir: sessionWorkDir, + runtimeProviderId: provider.id, + runtimeModelId: 'provider-fast', + }) + const found = await svc.findSessionFile(sessionId) + expect(found).not.toBeNull() + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-06-15T12:00:00.000Z', + cwd: sessionWorkDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'provider-returned-fast-alias', + content: [{ type: 'text', text: 'fast' }], + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }, + }) + '\n') + await svc.appendSessionMetadata(sessionId, { + workDir: sessionWorkDir, + runtimeProviderId: provider.id, + runtimeModelId: 'provider-big', + }) + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-06-15T12:01:00.000Z', + cwd: sessionWorkDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'provider-returned-big-alias', + content: [{ type: 'text', text: 'big' }], + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }, + }) + '\n') + + const usage = await svc.getTranscriptUsage(sessionId) + const windows = new Map(usage?.models.map((model) => [model.model, model.contextWindow])) + + expect(windows.get('provider-returned-fast-alias')).toBe(64_000) + expect(windows.get('provider-returned-big-alias')).toBe(1_000_000) + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + if (previousModelContextWindows === undefined) { + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + } else { + process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS = previousModelContextWindows + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }) + await fs.rm(workDir, { recursive: true, force: true }) + } + }) + it('should infer a unique saved provider context window for sessions missing runtime metadata', async () => { const previousConfigDir = process.env.CLAUDE_CONFIG_DIR const previousNodeEnv = process.env.NODE_ENV @@ -794,6 +985,83 @@ describe('ConversationService', () => { } }) + it('should not infer saved provider context windows for unrelated response model names', async () => { + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousNodeEnv = process.env.NODE_ENV + const previousModelContextWindows = process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-provider-unrelated-')) + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-provider-unrelated-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + process.env.NODE_ENV = 'development' + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + + try { + const providerService = new ProviderService() + await providerService.addProvider({ + presetId: 'custom', + name: 'Only Saved Provider', + apiKey: 'provider-key', + authStrategy: 'auth_token', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + models: { + main: 'configured-provider-main', + haiku: 'configured-provider-main', + sonnet: 'configured-provider-main', + opus: 'configured-provider-main', + }, + modelContextWindows: { + 'configured-provider-main': 1_000_000, + }, + }) + + const svc = new SessionService() + const { sessionId } = await svc.createSession(workDir) + const found = await svc.findSessionFile(sessionId) + expect(found).not.toBeNull() + + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-06-15T12:00:00.000Z', + cwd: workDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'unrelated-response-model', + content: [{ type: 'text', text: 'hello' }], + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }, + }) + '\n') + + const contextEstimate = await svc.getTranscriptContextEstimate(sessionId) + + expect(contextEstimate?.model).toBe('unrelated-response-model') + expect(contextEstimate?.rawMaxTokens).toBe(200_000) + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + if (previousModelContextWindows === undefined) { + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + } else { + process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS = previousModelContextWindows + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }) + await fs.rm(workDir, { recursive: true, force: true }) + } + }) + it('should not report transcript context as full for low-trust media usage spikes', async () => { const previousConfigDir = process.env.CLAUDE_CONFIG_DIR const previousNodeEnv = process.env.NODE_ENV @@ -1978,7 +2246,7 @@ describe('WebSocket Chat Integration', () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workDir: process.cwd() }), + body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'acceptEdits' }), }) expect(createRes.status).toBe(201) const { sessionId } = await createRes.json() as { sessionId: string } @@ -1998,6 +2266,35 @@ describe('WebSocket Chat Integration', () => { expect(messagesRes.status).toBe(200) const body = await messagesRes.json() as { messages: unknown[] } expect(body.messages).toEqual([]) + + const inspectionRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + expect(inspectionRes.status).toBe(200) + const inspection = await inspectionRes.json() as { status?: { permissionMode?: string } } + expect(inspection.status?.permissionMode).toBe('acceptEdits') + }) + + it('should preserve permission mode when clearing an inactive desktop session', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'acceptEdits' }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + expect(conversationService.hasSession(sessionId)).toBe(false) + + const clearTurn = await runTurn(sessionId, '/clear') + expect( + clearTurn.some( + (m) => m.type === 'system_notification' && m.subtype === 'session_cleared', + ), + ).toBe(true) + expect(clearTurn.some((m) => m.type === 'content_delta')).toBe(false) + + const inspectionRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + expect(inspectionRes.status).toBe(200) + const inspection = await inspectionRes.json() as { status?: { permissionMode?: string } } + expect(inspection.status?.permissionMode).toBe('acceptEdits') }) it('should reject /clear arguments without clearing the desktop session', async () => { diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 3d3636bd..e5639d94 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -755,6 +755,35 @@ describe('SessionService', () => { expect(detail!.messages[1]!.type).toBe('assistant') }) + it('should derive session detail modifiedAt from transcript messages', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const filePath = await writeSessionFile('-tmp-project', sessionId, [ + { + ...makeSessionMetaEntry('/tmp/project'), + timestamp: '2026-01-03T00:00:00.000Z', + }, + { + ...makeUserEntry('Earlier user work'), + timestamp: '2026-01-01T00:01:00.000Z', + }, + { + ...makeAssistantEntry('Earlier assistant reply'), + timestamp: '2026-01-01T00:02:00.000Z', + }, + { + type: 'custom-title', + customTitle: 'Later title metadata', + timestamp: '2026-01-04T00:00:00.000Z', + }, + ]) + const mtime = new Date('2026-01-05T00:00:00.000Z') + await fs.utimes(filePath, mtime, mtime) + + const detail = await service.getSession(sessionId) + + expect(detail?.modifiedAt).toBe('2026-01-01T00:02:00.000Z') + }) + it('should skip meta entries in messages', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ @@ -930,6 +959,92 @@ describe('SessionService', () => { ]) }) + it('should include linked subagent transcript changes in the message signature', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-project' + const agentId = 'abc123' + + await writeSessionFile(projectDir, sessionId, [ + makeSnapshotEntry(), + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'Agent:0', + name: 'Agent', + input: { description: 'Inspect alpha' }, + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:02.000Z', + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'Agent:0', + content: [ + { + type: 'text', + text: `alpha summary\nagentId: ${agentId}`, + }, + ], + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:03.000Z', + }, + ]) + const subagentFile = await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [ + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'Read:0', + name: 'Read', + input: { file_path: '/tmp/alpha.txt' }, + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:04.000Z', + }, + ]) + + const before = await service.getSessionMessagesSignature(sessionId) + + await fs.appendFile(subagentFile, `${JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'Read:0', + content: 'updated alpha body', + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:05.000Z', + })}\n`) + + const after = await service.getSessionMessagesSignature(sessionId) + + expect(before).not.toBe(after) + }) + it('should hide synthetic interruption, no-response, and malformed command breadcrumb transcript entries', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ @@ -1363,6 +1478,22 @@ describe('SessionService', () => { }) }) + it('should preserve permission metadata when clearing placeholder transcripts', async () => { + const workDir = path.join(tmpDir, 'clear-permission-workdir') + await fs.mkdir(workDir, { recursive: true }) + const { sessionId } = await (service.createSession as unknown as ( + workDir?: string, + repositoryOptions?: unknown, + permissionMode?: string, + ) => Promise<{ sessionId: string; workDir: string }>)(workDir, undefined, 'acceptEdits') + + await service.clearSessionTranscript(sessionId, workDir) + const launchInfo = await service.getSessionLaunchInfo(sessionId) + + expect(launchInfo?.workDir).toBe(await fs.realpath(workDir)) + expect(launchInfo?.permissionMode).toBe('acceptEdits') + }) + it('should persist session permission mode in launch metadata', async () => { const workDir = path.join(tmpDir, 'permission-workdir') await fs.mkdir(workDir, { recursive: true }) @@ -2535,6 +2666,35 @@ describe('Sessions API', () => { expect(res2.status).toBe(404) }) + it('DELETE /api/sessions/:id should invalidate recent projects cache', async () => { + const workDir = await fs.mkdtemp(path.join(tmpDir, 'recent-cache-delete-')) + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + const realWorkDir = await fs.realpath(workDir) + + const firstRecentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`) + expect(firstRecentRes.status).toBe(200) + const firstRecent = await firstRecentRes.json() as { + projects: Array<{ realPath: string }> + } + expect(firstRecent.projects.some((project) => project.realPath === realWorkDir)).toBe(true) + + const deleteRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }) + expect(deleteRes.status).toBe(200) + + const secondRecentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`) + expect(secondRecentRes.status).toBe(200) + const secondRecent = await secondRecentRes.json() as { + projects: Array<{ realPath: string }> + } + expect(secondRecent.projects.some((project) => project.realPath === realWorkDir)).toBe(false) + }) + it('DELETE /api/sessions/:id should remove matching IM adapter session mappings', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const otherSessionId = 'ffffffff-1111-2222-3333-ffffffffffff' diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 7f478b04..aa94521a 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -269,13 +269,15 @@ async function getSessionMessages(sessionId: string): Promise { } async function getSessionTrace(sessionId: string): Promise { - const [trace, sessionMeta] = await Promise.all([ + const [trace, sessionMeta, messageSignature] = await Promise.all([ traceCaptureService.getSessionTrace(sessionId), getSessionTraceMeta(sessionId), + sessionService.getSessionMessagesSignature(sessionId), ]) return Response.json({ ...trace, calls: trace.calls.map((call) => trimTraceCallPreviews(call)), + messageSignature, session: sessionMeta ? { id: sessionId, @@ -455,6 +457,7 @@ async function deleteSession(sessionId: string): Promise { } closeSessionConnection(sessionId, 'session deleted') cleanupAdapterSessionMappings(sessionId) + recentProjectsCache = null return Response.json({ ok: true }) } @@ -478,6 +481,9 @@ async function batchDeleteSessions(req: Request): Promise { closeSessionConnection(sessionId, 'session deleted') cleanupAdapterSessionMappings(sessionId) } + if (result.successes.length > 0) { + recentProjectsCache = null + } return Response.json({ ok: result.failures.length === 0, @@ -920,6 +926,8 @@ async function branchSession(req: Request, sessionId: string): Promise sourceWorktreeSession: launchInfo.worktreeSession, }) + recentProjectsCache = null + return Response.json({ sessionId: result.sessionId, title: result.title, diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 2a4c2084..2ce3bc26 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -92,6 +92,8 @@ export type SessionLaunchInfo = { effortLevel?: string } +type ProviderContextWindowHint = Pick + export type SessionInspectionTranscriptSnapshot = { launchInfo: SessionLaunchInfo metadata: TranscriptMetadataSnapshot @@ -322,6 +324,43 @@ const USER_INTERRUPTION_TEXTS = new Set([ const NO_RESPONSE_REQUESTED_TEXT = 'No response requested.' const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i const TASK_NOTIFICATION_BLOCK_RE = /\s*[\s\S]*?<\/task-notification>/i +const PROVIDER_MODEL_ALIAS_SEPARATORS = ['-', '_', ':', '/', '.', ' '] + +function normalizeProviderModelAlias(model: string): string { + return model + .trim() + .toLowerCase() + .replace(/\[1m\]$/i, '') + .replace(/:1m$/i, '') + .trim() +} + +function providerModelLooksRelated( + transcriptModel: string, + configuredModel: string, +): boolean { + const transcript = normalizeProviderModelAlias(transcriptModel) + const configured = normalizeProviderModelAlias(configuredModel) + if (!transcript || !configured) return false + if (transcript === configured) return true + + const shorter = Math.min(transcript.length, configured.length) + if (shorter < 6) return false + + return PROVIDER_MODEL_ALIAS_SEPARATORS.some((separator) => ( + transcript.startsWith(`${configured}${separator}`) || + configured.startsWith(`${transcript}${separator}`) + )) +} + +function safeJsonLength(value: unknown): number { + if (value === undefined) return 0 + try { + return JSON.stringify(value)?.length ?? 0 + } catch { + return 0 + } +} // ============================================================================ // Service @@ -752,6 +791,58 @@ export class SessionService { return undefined } + private resolveTranscriptModifiedAtFromEntries(entries: RawEntry[]): string | null { + let modifiedAt: string | null = null + for (const entry of entries) { + if ( + !entry.isMeta && + (entry.type === 'user' || entry.type === 'assistant') && + entry.message?.role + ) { + modifiedAt = this.latestTimestamp(modifiedAt, entry.timestamp) + } + } + return modifiedAt + } + + private resolveRuntimeContextMetadataFromEntries(entries: RawEntry[]): ProviderContextWindowHint { + let runtimeProviderId: string | null | undefined + let runtimeModelId: string | undefined + + for (const entry of entries) { + if (entry.type !== 'session-meta') continue + const record = entry as Record + if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { + runtimeProviderId = record.runtimeProviderId as string | null + } + if (typeof record.runtimeModelId === 'string') { + runtimeModelId = record.runtimeModelId + } + } + + return { + ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), + ...(runtimeModelId ? { runtimeModelId } : {}), + } + } + + private applyRuntimeContextMetadata( + hint: ProviderContextWindowHint, + entry: RawEntry, + ): ProviderContextWindowHint { + if (entry.type !== 'session-meta') return hint + + const record = entry as Record + const nextHint: ProviderContextWindowHint = { ...hint } + if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { + nextHint.runtimeProviderId = record.runtimeProviderId as string | null + } + if (typeof record.runtimeModelId === 'string') { + nextHint.runtimeModelId = record.runtimeModelId + } + return nextHint + } + private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] @@ -1015,6 +1106,18 @@ export class SessionService { return false } + private isVisibleTranscriptMessageEntry(entry: RawEntry): boolean { + if (!entry.message?.role || entry.isMeta) return false + if ( + entry.type !== 'user' && + entry.type !== 'assistant' && + entry.type !== 'system' + ) { + return false + } + return !this.shouldHideTranscriptEntry(entry) + } + private isGoalLocalCommandOutput(output: string): boolean { const trimmed = output.trim() return ( @@ -1492,7 +1595,7 @@ export class SessionService { private async getProviderContextWindowForSession( sessionId: string, model: string, - launchInfoOverride?: Pick | null, + launchInfoOverride?: ProviderContextWindowHint | null, ): Promise { const launchInfo = launchInfoOverride ?? await this.getSessionLaunchInfo(sessionId).catch(() => null) const providerIds: string[] = [] @@ -1519,8 +1622,8 @@ export class SessionService { for (const providerId of providerIds) { const env = await this.providerService.getProviderRuntimeEnv(providerId).catch(() => null) - const rawContextWindows = env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY] ?? null - // Step 1: Try matching the transcript model name directly (current behavior) + const rawContextWindows = env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY] ?? null + // Step 1: Try matching the transcript model name directly (current behavior) const contextWindow = getModelContextWindowFromEnvValue( model, rawContextWindows, @@ -1531,7 +1634,24 @@ export class SessionService { } return contextWindow } - // Step 2: If transcript model name didn't match, try matching with + + // Step 2: Prefer the model this session actually launched with. Some + // third-party APIs return provider-specific aliases, but Desktop persists + // the requested runtime model in session metadata. + if (launchInfo?.runtimeModelId) { + const runtimeModelWindow = getModelContextWindowFromEnvValue( + launchInfo.runtimeModelId, + rawContextWindows, + ) + if (runtimeModelWindow !== undefined) { + if (runtimeModelWindow > MODEL_CONTEXT_WINDOW_DEFAULT && is1mContextDisabled()) { + return MODEL_CONTEXT_WINDOW_DEFAULT + } + return runtimeModelWindow + } + } + + // Step 3: If transcript model name didn't match, try matching with // the provider's configured model names as fallback keys. // This handles the case where the API response returns a model name // that differs from the user-configured model name (e.g. provider @@ -1540,6 +1660,7 @@ export class SessionService { for (const envKey of providerEnvModelKeys) { const configuredModel = env[envKey] if (!configuredModel) continue + if (!providerModelLooksRelated(model, configuredModel)) continue const fallbackWindow = getModelContextWindowFromEnvValue( configuredModel, rawContextWindows, @@ -1564,7 +1685,7 @@ export class SessionService { private async getUniqueSavedProviderContextWindow(model: string): Promise { const { providers } = await this.providerService.listProviders().catch(() => ({ providers: [] })) const matches: number[] = [] - const providerEnvModelKeys = [ + const providerEnvModelKeys = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', @@ -1573,9 +1694,9 @@ export class SessionService { for (const provider of providers) { const env = await this.providerService.getProviderRuntimeEnv(provider.id).catch(() => null) - const rawContextWindows = env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY] ?? null + const rawContextWindows = env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY] ?? null // Step 1: Try matching the transcript model name directly - const contextWindow = getModelContextWindowFromEnvValue( + const contextWindow = getModelContextWindowFromEnvValue( model, rawContextWindows, ) @@ -1584,11 +1705,12 @@ export class SessionService { continue } - // Step 2: Fallback — try matching with provider's configured model names + // Step 2: Fallback to provider configured model names. if (env && rawContextWindows) { for (const envKey of providerEnvModelKeys) { const configuredModel = env[envKey] if (!configuredModel) continue + if (!providerModelLooksRelated(model, configuredModel)) continue const fallbackWindow = getModelContextWindowFromEnvValue( configuredModel, rawContextWindows, @@ -1610,7 +1732,7 @@ export class SessionService { return undefined } - const [contextWindow] = uniqueWindows + const contextWindow = [...uniqueWindows][0]! if ( contextWindow > MODEL_CONTEXT_WINDOW_DEFAULT && is1mContextDisabled() @@ -1623,7 +1745,7 @@ export class SessionService { private async getTranscriptContextWindow( sessionId: string, model: string, - launchInfo?: Pick | null, + launchInfo?: ProviderContextWindowHint | null, ): Promise { const providerContextWindow = await this.getProviderContextWindowForSession( sessionId, @@ -1682,7 +1804,7 @@ export class SessionService { }, estimatedTokensFromMessages: number, transcriptHasMediaInput: boolean, - launchInfo?: Pick | null, + launchInfo?: ProviderContextWindowHint | null, ): Promise { const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model, launchInfo) const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens @@ -1808,6 +1930,7 @@ export class SessionService { latest, estimatedTokensFromMessages, transcriptHasMediaInput, + this.resolveRuntimeContextMetadataFromEntries(entries), ) } @@ -1816,6 +1939,7 @@ export class SessionService { if (!found) return null const entries = await this.readJsonlFile(found.filePath) + let currentRuntimeHint: ProviderContextWindowHint = {} const models = new Map() let totalCostUSD = 0 let totalInputTokens = 0 @@ -1828,6 +1952,7 @@ export class SessionService { let lastUsageAt: number | null = null for (const entry of entries) { + currentRuntimeHint = this.applyRuntimeContextMetadata(currentRuntimeHint, entry) const usage = entry.message?.usage const model = entry.message?.model if (!usage || typeof model !== 'string') continue @@ -1877,7 +2002,7 @@ export class SessionService { webSearchRequests: 0, costUSD: 0, costDisplay: '$0.0000', - contextWindow: await this.getTranscriptContextWindow(sessionId, model), + contextWindow: await this.getTranscriptContextWindow(sessionId, model, currentRuntimeHint), maxOutputTokens: getModelMaxOutputTokens(model).default, } models.set(model, modelUsage) @@ -2333,7 +2458,7 @@ export class SessionService { id: sessionId, title, createdAt, - modifiedAt: stat.mtime.toISOString(), + modifiedAt: this.resolveTranscriptModifiedAtFromEntries(entries) ?? stat.mtime.toISOString(), messageCount: messages.length, projectPath: projectDir, projectRoot, @@ -2361,6 +2486,75 @@ export class SessionService { ) } + async getSessionMessagesSignature(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + let count = 0 + let last = '' + const agentToolUseIds = new Set() + const resultLinks = new Map() + await this.streamJsonlFile(found.filePath, (entry) => { + const agentToolUseId = this.extractAgentToolUseId(entry) + if (agentToolUseId) { + agentToolUseIds.add(agentToolUseId) + } + if (entry.message?.role === 'user' && Array.isArray(entry.message.content)) { + for (const block of entry.message.content as ContentBlock[]) { + if ( + block.type !== 'tool_result' || + typeof block.tool_use_id !== 'string' || + !agentToolUseIds.has(block.tool_use_id) + ) { + continue + } + const agentId = this.extractAgentIdFromResultText( + this.extractTextFromContent(block.content), + ) + if (agentId) { + resultLinks.set(block.tool_use_id, agentId) + } + } + } + if (!this.isVisibleTranscriptMessageEntry(entry)) return + count += 1 + const contentLength = safeJsonLength(entry.content) + safeJsonLength(entry.message?.content) + last = [ + entry.uuid ?? entry.messageId ?? '', + entry.type ?? '', + entry.timestamp ?? '', + entry.parentUuid ?? '', + entry.parent_tool_use_id ?? '', + contentLength, + ].join(':') + }) + + const subagentSignatures = await Promise.all( + [...resultLinks.entries()].map(async ([parentToolUseId, agentId]) => { + let childCount = 0 + let childLast = '' + await this.streamJsonlFile(this.subagentTranscriptPath(found.projectDir, sessionId, agentId), (entry) => { + if (!this.isVisibleTranscriptMessageEntry(entry)) return + childCount += 1 + const contentLength = safeJsonLength(entry.content) + safeJsonLength(entry.message?.content) + childLast = [ + parentToolUseId, + agentId, + entry.uuid ?? entry.messageId ?? '', + entry.type ?? '', + entry.timestamp ?? '', + entry.parentUuid ?? '', + entry.parent_tool_use_id ?? '', + contentLength, + ].join(':') + }) + return `${parentToolUseId}:${agentId}:${childCount}:${childLast}` + }), + ) + + return `${count}:${last}:${subagentSignatures.join('|')}` + } + /** * Create a new session file for the given working directory. */ @@ -2615,7 +2809,11 @@ export class SessionService { this.invalidateSessionListCache() } - async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise { + async clearSessionTranscript( + sessionId: string, + fallbackWorkDir?: string, + preservedPermissionMode?: string, + ): Promise { let found = await this.findSessionFile(sessionId) if (!found && fallbackWorkDir) { const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir)) @@ -2634,6 +2832,12 @@ export class SessionService { const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd() const repository = this.resolveRepositoryFromEntries(entries) + const permissionMode = ( + preservedPermissionMode && + VALID_SESSION_PERMISSION_MODES.has(preservedPermissionMode) + ) + ? preservedPermissionMode + : this.resolvePermissionModeFromEntries(entries) const now = new Date().toISOString() const initialEntry = { @@ -2652,6 +2856,7 @@ export class SessionService { isMeta: true, workDir, repository, + ...(permissionMode ? { permissionMode } : {}), timestamp: now, } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 91f35370..7b3f78eb 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -535,6 +535,9 @@ async function handleDesktopClearCommand( const { sessionId } = ws.data const workDir = conversationService.getSessionWorkDir(sessionId) + const permissionMode = conversationService.hasSession(sessionId) + ? conversationService.getSessionPermissionMode(sessionId) + : undefined conversationService.stopSession(sessionId) conversationService.clearOutputCallbacks(sessionId) sessionSlashCommands.delete(sessionId) @@ -542,7 +545,7 @@ async function handleDesktopClearCommand( cleanupStreamState(sessionId) try { - await sessionService.clearSessionTranscript(sessionId, workDir || undefined) + await sessionService.clearSessionTranscript(sessionId, workDir || undefined, permissionMode) } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) sendMessage(ws, { diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 981e5ecb..200134b1 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -453,25 +453,50 @@ export function configureEffortParams( betas: string[], model: string, ): void { - if (!modelSupportsEffort(model) || "effort" in outputConfig) { - return; + if ( + !modelSupportsEffort(model) || + 'effort' in outputConfig || + shouldSuppressEffortOutputConfig() + ) { + return } if (effortValue === undefined) { - outputConfig.effort = "high"; - betas.push(EFFORT_BETA_HEADER); - } else if (typeof effortValue === "string") { + outputConfig.effort = 'high' + betas.push(EFFORT_BETA_HEADER) + } else if (typeof effortValue === 'string') { // Send string effort level as is - outputConfig.effort = effortValue; - betas.push(EFFORT_BETA_HEADER); - } else if (process.env.USER_TYPE === "ant") { + outputConfig.effort = effortValue + betas.push(EFFORT_BETA_HEADER) + } else if (process.env.USER_TYPE === 'ant') { // Numeric effort override - ant-only (uses anthropic_internal) const existingInternal = - (extraBodyParams.anthropic_internal as Record) || {}; + (extraBodyParams.anthropic_internal as Record) || {} extraBodyParams.anthropic_internal = { ...existingInternal, effort_override: effortValue, - }; + } + } +} + +function shouldSuppressEffortOutputConfig(): boolean { + if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) { + return false + } + + const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '' + try { + const url = new URL(baseUrl) + const proxyPath = url.pathname.replace(/\/+$/, '') + const isLocalProxy = + (url.hostname === '127.0.0.1' || url.hostname === 'localhost') && + ( + proxyPath === '/proxy' || + proxyPath.startsWith('/proxy/providers/') + ) + return !isLocalProxy + } catch { + return true } } diff --git a/src/services/api/claudeEffort.test.ts b/src/services/api/claudeEffort.test.ts index 97df89e5..cd0b162a 100644 --- a/src/services/api/claudeEffort.test.ts +++ b/src/services/api/claudeEffort.test.ts @@ -10,6 +10,7 @@ describe('configureEffortParams', () => { let originalBedrock: string | undefined let originalVertex: string | undefined let originalFoundry: string | undefined + let originalDisableExperimentalBetas: string | undefined beforeEach(() => { originalBaseUrl = process.env.ANTHROPIC_BASE_URL @@ -18,6 +19,7 @@ describe('configureEffortParams', () => { originalBedrock = process.env.CLAUDE_CODE_USE_BEDROCK originalVertex = process.env.CLAUDE_CODE_USE_VERTEX originalFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY + originalDisableExperimentalBetas = process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS process.env.ANTHROPIC_BASE_URL = 'https://ark.cn-beijing.volces.com/api/coding' process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'glm-5.2' @@ -26,6 +28,7 @@ describe('configureEffortParams', () => { delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_FOUNDRY + delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS clearCapabilityCache() }) @@ -36,6 +39,7 @@ describe('configureEffortParams', () => { restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalBedrock) restoreEnv('CLAUDE_CODE_USE_VERTEX', originalVertex) restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalFoundry) + restoreEnv('CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', originalDisableExperimentalBetas) clearCapabilityCache() }) @@ -77,6 +81,68 @@ describe('configureEffortParams', () => { expect(extraBodyParams).toEqual({}) expect(betas).not.toContain(EFFORT_BETA_HEADER) }) + + test('does not send effort output_config when direct providers disable experimental betas', () => { + process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1' + + const outputConfig: Record = {} + const extraBodyParams: Record = {} + const betas: string[] = [] + + configureEffortParams( + 'high', + outputConfig, + extraBodyParams, + betas, + 'glm-5.2', + ) + + expect(outputConfig).toEqual({}) + expect(extraBodyParams).toEqual({}) + expect(betas).not.toContain(EFFORT_BETA_HEADER) + }) + + test('keeps effort output_config for local proxy providers so it can convert to reasoning_effort', () => { + process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1' + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:3456/proxy' + + const outputConfig: Record = {} + const extraBodyParams: Record = {} + const betas: string[] = [] + + configureEffortParams( + 'medium', + outputConfig, + extraBodyParams, + betas, + 'glm-5.2', + ) + + expect(outputConfig).toEqual({ effort: 'medium' }) + expect(extraBodyParams).toEqual({}) + expect(betas).toContain(EFFORT_BETA_HEADER) + }) + + test('keeps effort output_config for provider-specific local proxy routes', () => { + process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1' + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:3456/proxy/providers/provider-1' + + const outputConfig: Record = {} + const extraBodyParams: Record = {} + const betas: string[] = [] + + configureEffortParams( + 'high', + outputConfig, + extraBodyParams, + betas, + 'glm-5.2', + ) + + expect(outputConfig).toEqual({ effort: 'high' }) + expect(extraBodyParams).toEqual({}) + expect(betas).toContain(EFFORT_BETA_HEADER) + }) }) function restoreEnv(key: string, value: string | undefined) {