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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 22:11:43 +08:00
parent e6de5fde3a
commit 35f43e8289
22 changed files with 1137 additions and 56 deletions

View File

@ -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(<ChatInput variant="hero" />)
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',

View File

@ -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, {

View File

@ -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 () => {

View File

@ -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()
})
})

View File

@ -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
}

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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<SessionStore>((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<SessionStore>((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<SessionStore>((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<SessionStore>((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)
}

View File

@ -82,6 +82,7 @@ export type TraceSessionSummary = {
export type TraceSession = {
sessionId: string
messageSignature?: string | null
session?: {
id: string
title: string

View File

@ -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')
}

View File

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

View File

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

View File

@ -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,

View File

@ -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')

View File

@ -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 () => {

View File

@ -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'

View File

@ -269,13 +269,15 @@ async function getSessionMessages(sessionId: string): Promise<Response> {
}
async function getSessionTrace(sessionId: string): Promise<Response> {
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<Response> {
}
closeSessionConnection(sessionId, 'session deleted')
cleanupAdapterSessionMappings(sessionId)
recentProjectsCache = null
return Response.json({ ok: true })
}
@ -478,6 +481,9 @@ async function batchDeleteSessions(req: Request): Promise<Response> {
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<Response>
sourceWorktreeSession: launchInfo.worktreeSession,
})
recentProjectsCache = null
return Response.json({
sessionId: result.sessionId,
title: result.title,

View File

@ -92,6 +92,8 @@ export type SessionLaunchInfo = {
effortLevel?: string
}
type ProviderContextWindowHint = Pick<SessionLaunchInfo, 'runtimeProviderId' | 'runtimeModelId'>
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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const TASK_NOTIFICATION_BLOCK_RE = /<task-notification>\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<string, unknown>
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<string, unknown>
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<SessionLaunchInfo, 'runtimeProviderId'> | null,
launchInfoOverride?: ProviderContextWindowHint | null,
): Promise<number | undefined> {
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<number | undefined> {
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<SessionLaunchInfo, 'runtimeProviderId'> | null,
launchInfo?: ProviderContextWindowHint | null,
): Promise<number> {
const providerContextWindow = await this.getProviderContextWindowForSession(
sessionId,
@ -1682,7 +1804,7 @@ export class SessionService {
},
estimatedTokensFromMessages: number,
transcriptHasMediaInput: boolean,
launchInfo?: Pick<SessionLaunchInfo, 'runtimeProviderId'> | null,
launchInfo?: ProviderContextWindowHint | null,
): Promise<TranscriptContextEstimate> {
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<string, TranscriptUsageSnapshot['models'][number]>()
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<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
let count = 0
let last = ''
const agentToolUseIds = new Set<string>()
const resultLinks = new Map<string, string>()
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<void> {
async clearSessionTranscript(
sessionId: string,
fallbackWorkDir?: string,
preservedPermissionMode?: string,
): Promise<void> {
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,
}

View File

@ -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, {

View File

@ -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<string, unknown>) || {};
(extraBodyParams.anthropic_internal as Record<string, unknown>) || {}
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
}
}

View File

@ -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<string, unknown> = {}
const extraBodyParams: Record<string, unknown> = {}
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<string, unknown> = {}
const extraBodyParams: Record<string, unknown> = {}
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<string, unknown> = {}
const extraBodyParams: Record<string, unknown> = {}
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) {