mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
e6de5fde3a
commit
35f43e8289
@ -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',
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
48
desktop/src/lib/recentProjectsCache.test.ts
Normal file
48
desktop/src/lib/recentProjectsCache.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
20
desktop/src/lib/recentProjectsCache.ts
Normal file
20
desktop/src/lib/recentProjectsCache.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -82,6 +82,7 @@ export type TraceSessionSummary = {
|
||||
|
||||
export type TraceSession = {
|
||||
sessionId: string
|
||||
messageSignature?: string | null
|
||||
session?: {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user