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) {