fix: resolve reproduced issue regressions and add Zed support

- Restore file-tree Add to chat and keyboard menu behavior (#1322).
- Preserve nested OpenAI cache hits beside zero-valued cache fields (#1327).
- Allow exact development renderer CORS preflights while retaining auth (#1335).
- Prevent stale transcripts from restoring explicitly cleared goals (#1339).
- Recover interrupted auto-dream attempts while preserving legacy stamps (#1349).
- Add Zed to the existing editor discovery and launch menu (#1346).

Refs #1322, #1327, #1335, #1339, #1349, #1346

Validation: local before/after reproductions, browser smoke, server 5537
and desktop 6086 tests passed; provider/chat/agent-flow/persistence checks
passed. Changed-line coverage is 98.23%. The full coverage gate remains
blocked by the unchanged IM slow-drip test; native signing/package
validation is incomplete.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-20 20:29:40 +08:00
parent 42915081ca
commit 83b0242558
18 changed files with 454 additions and 14 deletions
+10
View File
@@ -26,6 +26,7 @@ describe('desktop dev launcher environment', () => {
})
expect(env.ELECTRON_RENDERER_URL).toBe(DEFAULT_RENDERER_URL)
expect(env.CC_HAHA_TRUSTED_RENDERER_ORIGIN).toBe(DEFAULT_RENDERER_URL)
expect(env.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1')
expect(env.no_proxy).toBe(env.NO_PROXY)
})
@@ -41,6 +42,15 @@ describe('desktop dev launcher environment', () => {
expect(env.no_proxy).toBe(env.NO_PROXY)
})
it('derives the exact trusted origin from the validated renderer URL', () => {
const env = createElectronDevEnv({
ELECTRON_RENDERER_URL: ' http://localhost:1777/app?dev=1 ',
CC_HAHA_TRUSTED_RENDERER_ORIGIN: 'https://untrusted.example',
})
expect(env.CC_HAHA_TRUSTED_RENDERER_ORIGIN).toBe('http://localhost:1777')
expect(() => createElectronDevEnv({ ELECTRON_RENDERER_URL: 'https://untrusted.example' })).toThrow('Refusing non-local')
})
it('deduplicates no_proxy entries', () => {
expect(mergeNoProxy('localhost,127.0.0.1')).toBe('localhost,127.0.0.1,::1')
})
+7 -1
View File
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer, type ViteDevServer } from 'vite'
import { resolveRendererEntry } from '../electron/services/rendererEntry'
export const DEFAULT_RENDERER_URL = 'http://localhost:1420'
export const LOCAL_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1']
@@ -19,11 +20,16 @@ export function mergeNoProxy(existing: string | undefined, required = LOCAL_NO_P
}
export function createElectronDevEnv(env: NodeJS.ProcessEnv = process.env) {
const rendererUrl = env.ELECTRON_RENDERER_URL ?? DEFAULT_RENDERER_URL
const rendererUrl = resolveRendererEntry({
isPackaged: false,
appRoot: '',
env: { ELECTRON_RENDERER_URL: env.ELECTRON_RENDERER_URL?.trim() || DEFAULT_RENDERER_URL },
})
const noProxy = mergeNoProxy(env.NO_PROXY ?? env.no_proxy)
return {
...env,
ELECTRON_RENDERER_URL: rendererUrl,
CC_HAHA_TRUSTED_RENDERER_ORIGIN: new URL(rendererUrl).origin,
NO_PROXY: noProxy,
no_proxy: noProxy,
}
@@ -19,6 +19,7 @@ vi.mock('../../api/sessions', () => ({
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
import { WorkspaceFileTreePane } from './WorkspaceFileTreePane'
import { useWorkspaceChatContextStore } from '@/stores/workspaceChatContextStore'
const SESSION = 'session-a'
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView
@@ -460,3 +461,41 @@ describe('filter field', () => {
expect(screen.getByTestId('workspace-file-tree-filter')).toHaveValue('')
})
})
// #1322: a whole file must be attachable without opening it and selecting lines.
describe('file chat references', () => {
beforeEach(() => useWorkspaceChatContextStore.setState({ referencesBySession: {} }))
it('adds a right-clicked file to its own session without opening it', async () => {
const { onOpen } = await renderPane()
fireEvent.contextMenu(screen.getByTestId('workspace-tree-row-README.md'), { clientX: 30, clientY: 40 })
fireEvent.click(screen.getByRole('menuitem', { name: 'Add to chat' }))
expect(useWorkspaceChatContextStore.getState().referencesBySession[SESSION]).toEqual([
expect.objectContaining({ kind: 'file', path: 'README.md', name: 'README.md' }),
])
expect(onOpen).not.toHaveBeenCalled()
expect(screen.queryByRole('menu')).toBeNull()
})
it('supports keyboard invocation and dismisses without adding', async () => {
await renderPane()
const file = screen.getByTestId('workspace-tree-row-README.md')
fireEvent.keyDown(file, { key: 'F10', shiftKey: true })
expect(screen.getByRole('menuitem', { name: 'Add to chat' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('menuitem'), { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(useWorkspaceChatContextStore.getState().referencesBySession[SESSION]).toBeUndefined()
})
it('closes an old session menu when switching sessions', async () => {
const view = await renderPane()
fireEvent.contextMenu(screen.getByTestId('workspace-tree-row-README.md'))
expect(screen.getByRole('menuitem', { name: 'Add to chat' })).toBeInTheDocument()
await act(async () => {
view.rerender(<WorkspaceFileTreePane sessionId="session-b" selectedPath={null} onOpen={vi.fn()} />)
await Promise.resolve()
})
expect(screen.queryByRole('menu')).toBeNull()
expect(useWorkspaceChatContextStore.getState().referencesBySession).toEqual({})
})
})
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { SearchField } from '@/components/ui/SearchField'
import { Button } from '@/components/ui/Button'
@@ -9,6 +9,10 @@ import { useTranslation } from '../../i18n'
import { EMPTY_WORKSPACE_TREE_VIEW, useWorkspaceContentStore } from '../../stores/workspaceContentStore'
import { basenameOf } from '../../lib/workspace/types'
import { useRovingTree } from './treeKeyboard'
import { useWorkspaceChatContextStore } from '@/stores/workspaceChatContextStore'
import { useDismissable } from '@/hooks/useDismissable'
import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
import { useMenuKeyboard } from '@/components/workbench/menuKeyboard'
export type WorkspaceFileTreePaneProps = {
sessionId: string
@@ -42,6 +46,21 @@ export function WorkspaceFileTreePane({
autoFocus = false,
}: WorkspaceFileTreePaneProps) {
const t = useTranslation()
const [contextMenu, setContextMenu] = useState<{ sessionId: string; row: TreeRow; x: number; y: number } | null>(null)
const menu = contextMenu?.sessionId === sessionId ? contextMenu : null
const menuRef = useRef<HTMLDivElement>(null)
const menuTriggerRef = useRef<HTMLElement | null>(null)
const closeMenu = useCallback(() => setContextMenu(null), [])
useEffect(closeMenu, [closeMenu, sessionId])
useDismissable({ open: menu !== null, refs: [menuRef], onDismiss: closeMenu })
const handleMenuKeyDown = useMenuKeyboard({ open: menu !== null, menuRef, triggerRef: menuTriggerRef, onClose: closeMenu })
const menuPosition = useAnchoredPosition({
open: menu !== null,
anchorRect: { top: menu?.y ?? 0, bottom: menu?.y ?? 0, left: menu?.x ?? 0, right: menu?.x ?? 0 },
floatingRef: menuRef,
offset: 0,
clampHeight: true,
})
const treeView = useWorkspaceContentStore((state) => state.treeViewBySession[sessionId] ?? EMPTY_WORKSPACE_TREE_VIEW)
const setTreeView = useWorkspaceContentStore((state) => state.setTreeView)
const { filter } = treeView
@@ -312,7 +331,21 @@ export function WorkspaceFileTreePane({
handleActivate(row)
}}
onFocus={() => setFocusedPath(row.path)}
onKeyDown={(event) => handleKeyDown(event, row)}
onContextMenu={(event) => {
event.preventDefault()
menuTriggerRef.current = event.currentTarget
setContextMenu({ sessionId, row, x: event.clientX, y: event.clientY })
}}
onKeyDown={(event) => {
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
menuTriggerRef.current = event.currentTarget
setContextMenu({ sessionId, row, x: rect.left, y: rect.bottom })
return
}
handleKeyDown(event, row)
}}
style={{ paddingLeft: 6 + row.depth * 16 }}
className={[
'relative flex h-[34px] cursor-default items-center gap-1.5 rounded-[var(--radius-sm)] pr-2 text-[14px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
@@ -339,6 +372,26 @@ export function WorkspaceFileTreePane({
})
)}
</div>
{menu ? (
<div
ref={menuRef}
role="menu"
aria-label={t('workspace.addSelectionToChat')}
onKeyDown={handleMenuKeyDown}
style={menuPosition.style}
className="fixed z-[var(--z-dropdown)] min-w-[160px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-dropdown)]"
>
<Button role="menuitem" variant="ghost" size="sm" onClick={() => {
useWorkspaceChatContextStore.getState().addReference(sessionId, {
kind: 'file', path: menu.row.path, name: menu.row.name,
isDirectory: menu.row.isDirectory,
})
closeMenu()
}}>
{t('workspace.addSelectionToChat')}
</Button>
</div>
) : null}
</div>
)
}
+16
View File
@@ -128,6 +128,22 @@ describe('goalState', () => {
expect(cleared).toBeNull()
})
test('does not resurrect a cleared goal from an in-flight turn transcript (#1339)', () => {
const { appState, context } = hookContext()
const transcript = [
createCommandInputMessage('<local-command-stdout>Goal set: ship it</local-command-stdout>'),
]
setThreadGoalHook(context, 'thread-cleared-in-flight', 'ship it')
clearThreadGoalHook(context, 'thread-cleared-in-flight')
expect(ensureThreadGoalHookFromTranscript(context, 'thread-cleared-in-flight', transcript)).toBeNull()
expect(appState.sessionHooks.get('thread-cleared-in-flight')?.hooks.Stop).toBeUndefined()
setThreadGoalHook(context, 'thread-cleared-in-flight', 'new goal')
expect(getThreadGoal('thread-cleared-in-flight')?.objective).toBe('new goal')
clearThreadGoalHook(context, 'thread-cleared-in-flight')
})
test('identifies standalone goal local command output for SDK forwarding', () => {
expect(
isGoalLocalCommandOutputContent(
+6
View File
@@ -31,6 +31,9 @@ const GOAL_HOOK_MARKER = '<cc-haha-goal-hook>'
const GOAL_HOOK_TIMEOUT_SECONDS = 45
const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete'])
const goalsByThread = new Map<string, ThreadGoal>()
// A running turn can still hold a transcript from before /goal clear.
// Explicit session state must win over those stale transcript anchors.
const clearedGoalThreads = new Set<string>()
export function parseGoalCommand(args: string): ParsedGoalCommand {
const trimmed = args.trim()
@@ -62,6 +65,7 @@ export function setThreadGoalHook(
now = Date.now(),
): ThreadGoal {
clearThreadGoalHook(context, threadId)
clearedGoalThreads.delete(threadId)
const hook = createGoalPromptHook(objective)
const goal: ThreadGoal = {
@@ -97,6 +101,7 @@ export function clearThreadGoalHook(
context: Pick<ToolUseContext, 'setAppState'>,
threadId: string,
): ThreadGoal | null {
clearedGoalThreads.add(threadId)
const goal = goalsByThread.get(threadId) ?? null
if (goal) {
removeSessionHook(context.setAppState, threadId, 'Stop', goal.hook)
@@ -113,6 +118,7 @@ export function ensureThreadGoalHookFromTranscript(
): ThreadGoal | null {
const current = goalsByThread.get(threadId)
if (current) return current
if (clearedGoalThreads.has(threadId)) return null
const restored = findActiveGoalObjective(messages)
if (!restored) return null
@@ -23,6 +23,7 @@ let originalH5DistDir: string | undefined
let originalClaudeAppRoot: string | undefined
let originalServerAuthRequired: string | undefined
let originalLocalAccessToken: string | undefined
let originalTrustedRendererOrigin: string | undefined
let originalPetAccessToken: string | undefined
let originalServerPort = 3456
const PHONE_ORIGIN = 'https://phone.example'
@@ -221,6 +222,8 @@ beforeEach(async () => {
originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED
originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
originalTrustedRendererOrigin = process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN
delete process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN
originalPetAccessToken = process.env.CC_HAHA_PET_ACCESS_TOKEN
originalServerPort = ProviderService.getServerPort()
process.env.CLAUDE_CONFIG_DIR = tmpDir
@@ -257,6 +260,8 @@ afterEach(async () => {
else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired
if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken
if (originalTrustedRendererOrigin === undefined) delete process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN
else process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN = originalTrustedRendererOrigin
if (originalPetAccessToken === undefined) delete process.env.CC_HAHA_PET_ACCESS_TOKEN
else process.env.CC_HAHA_PET_ACCESS_TOKEN = originalPetAccessToken
@@ -269,6 +274,34 @@ afterEach(async () => {
})
describe('remote H5 auth and CORS integration', () => {
test('allows only the configured dev renderer preflight with a desktop process token', async () => {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'fixture-desktop-token'
process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN = 'http://localhost:1420'
await restartRemoteServer()
const headers = {
Origin: 'http://localhost:1420',
'Access-Control-Request-Method': 'GET',
'Access-Control-Request-Headers': 'authorization,content-type',
}
const preflight = await fetch(`${baseUrl}/api/settings`, { method: 'OPTIONS', headers })
expect(preflight.status).toBe(204)
expect(preflight.headers.get('Access-Control-Allow-Origin')).toBe(headers.Origin)
const authenticated = await fetch(`${baseUrl}/api/settings`, {
headers: { Origin: headers.Origin, Authorization: 'Bearer fixture-desktop-token' },
})
expect(authenticated.status).toBe(200)
const unauthenticated = await fetch(`${baseUrl}/api/settings`, { headers: { Origin: headers.Origin } })
expect(unauthenticated.status).toBe(403)
for (const origin of ['http://localhost:5173', 'http://127.0.0.1:1420', 'http://localhost:1421', 'http://[::1]:1420']) {
const response = await fetch(`${baseUrl}/api/settings`, { method: 'OPTIONS', headers: { ...headers, Origin: origin } })
expect(response.status).toBe(403)
}
for (const endpoint of ['/api/h5-access', '/api/h5-access/enable', '/api/settings/session-cleanup']) {
const response = await fetch(`${baseUrl}${endpoint}`, { method: 'OPTIONS', headers })
expect(response.status).toBe(403)
}
})
test('serves the packaged H5 shell and static assets from the remote server', async () => {
const shellResponse = await fetch(`${baseUrl}/`)
expect(shellResponse.status).toBe(200)
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import {
classifyH5Request,
resolveTrustedRendererOrigin,
isLocalCredentialOnlyPath,
isLoopbackHost,
requiresLocalAccessCredential,
@@ -16,6 +17,22 @@ const localContext = { clientAddress: '127.0.0.1' }
const remoteContext = { clientAddress: '192.168.0.44' }
describe('h5AccessPolicy', () => {
test('validates the development renderer origin before granting a preflight exception', () => {
expect(resolveTrustedRendererOrigin('http://localhost:1420/app')).toBe('http://localhost:1420')
for (const value of [undefined, '', 'invalid', 'file://', 'https://remote.example', 'http://user:pass@localhost:1420']) {
expect(resolveTrustedRendererOrigin(value)).toBeNull()
}
const request = req('http://127.0.0.1:3456/api/settings', {
method: 'OPTIONS',
headers: { Origin: 'http://localhost:1420', 'Access-Control-Request-Method': 'GET' },
})
const context = { ...localContext, localAccessTokenConfigured: true, trustedRendererOrigin: 'http://localhost:1420' }
expect(classifyH5Request(request, new URL(request.url), context)).toBe('local-trusted')
expect(classifyH5Request(request, new URL(request.url), { ...context, ...remoteContext })).toBe('h5-browser')
request.headers.set('X-Forwarded-For', '192.168.0.44')
expect(classifyH5Request(request, new URL(request.url), context)).toBe('h5-browser')
})
test('recognizes loopback hosts as local trusted requests', () => {
expect(isLoopbackHost('localhost')).toBe(true)
expect(isLoopbackHost('127.0.0.1')).toBe(true)
@@ -152,12 +152,65 @@ describe('openTargetService', () => {
.toBe('/api/open-targets/icons/vscode')
})
it.each(['/Applications/Zed.app', join(homedir(), 'Applications', 'Zed.app')])(
'detects Zed installed at %s and opens projects and files through the app bundle',
async (appPath) => {
const dir = await makeDir('cc-haha zed-')
const file = join(dir, 'hello world.txt')
await writeFile(file, 'fixture')
const state = createService('darwin', { paths: { [appPath]: true } })
try {
expect((await state.service.listTargets()).targets).toContainEqual({
id: 'zed', kind: 'ide', label: 'Zed', icon: 'zed',
iconUrl: '/api/open-targets/icons/zed', platform: 'darwin',
})
await state.service.openTarget({ targetId: 'zed', path: dir })
await state.service.openTarget({ targetId: 'zed', path: file })
expect(state.launched).toEqual([
{ command: 'open', args: ['-a', appPath, dir] },
{ command: 'open', args: ['-a', appPath, file] },
])
} finally {
await rm(dir, { recursive: true, force: true })
}
},
)
it.each(['zed', 'zeditor'])('detects and launches Linux Zed using %s', async (command) => {
const dir = await makeDir('cc-haha zed-')
const state = createService('linux', { commands: { [command]: true } })
try {
expect((await state.service.listTargets()).targets.map((target) => target.id)).toEqual(['zed'])
await state.service.openTarget({ targetId: 'zed', path: dir })
expect(state.launched).toEqual([{ command, args: [dir] }])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('detects and launches the resolved Windows Zed executable', async () => {
const dir = await makeDir('cc-haha zed-')
const executablePath = 'C:/fixture/Zed/zed.exe'
const state = createService('win32', {
commandPaths: { 'zed.exe': executablePath },
paths: { [executablePath]: true },
})
try {
expect((await state.service.listTargets()).targets.map((target) => target.id)).toEqual(['zed', 'explorer'])
await state.service.openTarget({ targetId: 'zed', path: dir })
expect(state.launched).toEqual([{ command: executablePath, args: [dir] }])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('does not treat macOS command shims as installed IDEs without the app bundle', async () => {
const { service } = createService('darwin', {
commands: {
code: true,
goland: true,
pycharm: true,
zed: true,
},
})
+15
View File
@@ -1,6 +1,7 @@
export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser'
export type H5RequestContext = {
clientAddress: string | null
trustedRendererOrigin?: string | null
localAccessTokenConfigured?: boolean
localAccessAuthorized?: boolean
internalSdkAuthorized?: boolean
@@ -76,6 +77,14 @@ function isLoopbackBrowserOrigin(origin: string): boolean {
return isLoopbackHost(parsed.hostname)
}
/** Accept only an explicitly configured loopback web origin from the dev launcher. */
export function resolveTrustedRendererOrigin(value: string | undefined): string | null {
if (!value || !isLoopbackBrowserOrigin(value)) return null
const parsed = new URL(value)
if (parsed.username || parsed.password) return null
return parsed.origin
}
function pathnameDirectory(pathname: string): string {
const slash = pathname.lastIndexOf('/')
return slash < 0 ? '/' : pathname.slice(0, slash + 1)
@@ -187,6 +196,12 @@ function isLocalDesktopOrNavigationOrigin(
): boolean {
if (!origin) return !isCrossSiteSubresource(request.headers)
if (LOCAL_DESKTOP_ORIGINS.has(origin)) return true
// Chromium omits Authorization on CORS preflight. Exempt only the dev
// launcher's exact origin and only OPTIONS; real requests still need the
// desktop process credential, including the H5 control plane.
if (request.method === 'OPTIONS' &&
origin === context.trustedRendererOrigin &&
request.headers.has('Access-Control-Request-Method')) return true
// A configured process credential distinguishes the Electron renderer from
// arbitrary pages served by another loopback process. Keep tokenless
+3
View File
@@ -29,6 +29,7 @@ import { ensurePersistentStorageUpgraded } from './services/persistentStorageMig
import { handleStaticH5Request } from './staticH5.js'
import {
classifyH5Request,
resolveTrustedRendererOrigin,
isH5AccessControlPath,
isLocalCredentialOnlyPath,
requiresLocalAccessCredential,
@@ -221,6 +222,7 @@ function originFromUrl(value: string | null): string | null {
export function startServer(port = PORT, host = HOST) {
enableConfigs()
const trustedRendererOrigin = resolveTrustedRendererOrigin(process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN)
// Warm the synchronous disconnect-grace cache from managed settings so the
// first client disconnect honors the configured value (issue #764).
void refreshDisconnectGraceMs()
@@ -333,6 +335,7 @@ export function startServer(port = PORT, host = HOST) {
const sdkToken = url.searchParams.get('token')
const h5RequestContext = {
clientAddress,
trustedRendererOrigin,
localAccessTokenConfigured:
hasConfiguredLocalAccessToken() || hasConfiguredPetAccessToken(),
localAccessAuthorized:
@@ -134,3 +134,10 @@ describe('Chat stream protocol boundaries', () => {
expect(cancelled).toBe(true)
})
})
test('nested prompt cache survives zero direct cache creation in streaming usage (#1327)', async () => {
const usage = { prompt_tokens: 149293, completion_tokens: 551, cache_creation_input_tokens: 0, prompt_tokens_details: { cached_tokens: 147840 } }
const events = await collect(chunk({ content: 'Done' }) + chunk({}, 'stop')
+ `data: ${JSON.stringify({ choices: [], usage })}\n\ndata: [DONE]\n\n`)
expect(events.find(e => e.type === 'message_delta').usage).toMatchObject({ input_tokens: 1453, output_tokens: 551, cache_read_input_tokens: 147840 })
})
@@ -28,3 +28,13 @@ describe('Chat non-streaming response integrity', () => {
expect(result.content.some(block => block.type === 'tool_use')).toBe(false)
})
})
// Issue #1327: zero-valued compatibility cache fields must not hide OpenAI usage.
for (const direct of [{ cache_creation_input_tokens: 0 }, { cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }]) {
test(`nested prompt cache survives zero direct fields ${JSON.stringify(direct)}`, () => {
const upstream = response({}, 'stop')
upstream.choices[0].message.tool_calls = undefined
upstream.usage = { prompt_tokens: 149293, completion_tokens: 551, prompt_tokens_details: { cached_tokens: 147840 }, ...direct }
expect(openaiChatToAnthropic(upstream, 'fixture').usage).toEqual({ input_tokens: 1453, output_tokens: 551, cache_read_input_tokens: 147840 })
})
}
+4 -2
View File
@@ -58,12 +58,14 @@ export function openaiUsageToAnthropic(usage: OpenAICompatibleUsage | undefined)
usage.cache_creation_input_tokens,
)
const hasDirectCacheUsage =
directCacheRead !== undefined || directCacheCreation !== undefined
(directCacheRead ?? 0) > 0 || (directCacheCreation ?? 0) > 0
const nestedCacheRead = validTokenCount(
usage.input_tokens_details?.cached_tokens,
) ?? validTokenCount(usage.prompt_tokens_details?.cached_tokens) ?? 0
// Direct cache_* fields use Anthropic's exclusive-input semantics. Nested
// Positive direct cache_* fields use Anthropic's exclusive-input semantics.
// Some Chat gateways include zero placeholders alongside real nested counts;
// those placeholders must not suppress the nested cache hit accounting. Nested
// OpenAI details are inclusive in input/prompt and must be subtracted. Cap
// malformed nested cache counts at the reported input to preserve totals.
const cacheRead = hasDirectCacheUsage
+16
View File
@@ -142,6 +142,22 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
darwin: ['/Applications/Cursor.app', posixPath.join(homedir(), 'Applications', 'Cursor.app')],
},
},
{
id: 'zed',
kind: 'ide',
label: 'Zed',
icon: 'zed',
platforms: ['darwin', 'win32', 'linux'],
commands: {
darwin: ['zed'],
win32: ['zed.exe'],
linux: ['zed', 'zeditor'],
},
windowsExecutableNames: ['zed.exe'],
appPaths: {
darwin: ['/Applications/Zed.app', posixPath.join(homedir(), 'Applications', 'Zed.app')],
},
},
{
id: 'sublime',
kind: 'ide',
+2
View File
@@ -40,6 +40,7 @@ import {
listSessionsTouchedSince,
tryAcquireConsolidationLock,
rollbackConsolidationLock,
completeConsolidationLock,
} from './consolidationLock.js'
import {
registerDreamTask,
@@ -232,6 +233,7 @@ ${sessionIds.map(id => `- ${id}`).join('\n')}`
onMessage: makeDreamProgressWatcher(taskId, setAppState),
})
await completeConsolidationLock()
completeDreamTask(taskId, setAppState)
// Inline completion summary in the main transcript (same surface as
// extractMemories's "Saved N memories" message).
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { mkdtemp, rm, readFile, writeFile, utimes } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getAutoMemPath } from '../../memdir/paths.js'
import {
readLastConsolidatedAt,
tryAcquireConsolidationLock,
completeConsolidationLock,
rollbackConsolidationLock,
recordConsolidation,
} from './consolidationLock.js'
let directory: string
let previousOverride: string | undefined
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'consolidation-lock-'))
previousOverride = process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE = directory
getAutoMemPath.cache.clear?.()
})
afterEach(async () => {
if (previousOverride === undefined) delete process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
else process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE = previousOverride
getAutoMemPath.cache.clear?.()
await rm(directory, { recursive: true, force: true })
})
for (const ageHours of [0, 2]) {
test(`preserves a legacy successful PID stamp after its process exits (${ageHours}h old, #1349)`, async () => {
const child = Bun.spawn([process.execPath, '--eval', ''], { stdout: 'ignore', stderr: 'ignore' })
await child.exited
const lock = join(directory, '.consolidate-lock')
await writeFile(lock, String(child.pid))
const previous = (Date.now() - ageHours * 3_600_000) / 1000
await utimes(lock, previous, previous)
expect(await readLastConsolidatedAt()).toBeCloseTo(previous * 1000, 0)
})
}
test('preserves completed blank-body legacy timestamps', async () => {
const lock = join(directory, '.consolidate-lock')
await writeFile(lock, '')
expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000)
})
test('preserves a live holder timestamp', async () => {
await writeFile(join(directory, '.consolidate-lock'), String(process.pid))
expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000)
})
test('successful consolidation releases its holder while preserving the timestamp', async () => {
expect(await tryAcquireConsolidationLock()).toBe(0)
const acquired = await readLastConsolidatedAt()
expect(await tryAcquireConsolidationLock()).toBeNull()
await completeConsolidationLock()
expect(await readLastConsolidatedAt()).toBeCloseTo(acquired, 0)
// A completed attempt no longer excludes its own process from acquiring.
expect(await tryAcquireConsolidationLock()).toBeCloseTo(acquired, 0)
})
async function leaveInterruptedAttempt(priorMtime: number): Promise<void> {
const lock = join(directory, '.consolidate-lock')
if (priorMtime > 0) {
await writeFile(lock, '')
await utimes(lock, priorMtime / 1000, priorMtime / 1000)
}
const moduleUrl = new URL('./consolidationLock.ts', import.meta.url).href
const child = Bun.spawn([process.execPath, '--eval',
`const { tryAcquireConsolidationLock } = await import(${JSON.stringify(moduleUrl)}); await tryAcquireConsolidationLock()`,
], { env: { ...process.env }, stdout: 'ignore', stderr: 'pipe' })
expect(await child.exited).toBe(0)
}
for (const ageHours of [0, 2]) {
test(`recovers a recognizably interrupted new attempt before the time gate (${ageHours}h old)`, async () => {
const priorMtime = Date.now() - 48 * 3_600_000
await leaveInterruptedAttempt(priorMtime)
const attemptTime = (Date.now() - ageHours * 3_600_000) / 1000
await utimes(join(directory, '.consolidate-lock'), attemptTime, attemptTime)
expect(await readLastConsolidatedAt()).toBeCloseTo(priorMtime, 0)
const prior = await tryAcquireConsolidationLock()
expect(prior).toBeCloseTo(priorMtime, 0)
await rollbackConsolidationLock(prior!)
expect(await readLastConsolidatedAt()).toBeCloseTo(priorMtime, 0)
})
}
test('recovers an interrupted first attempt with no prior timestamp', async () => {
await leaveInterruptedAttempt(0)
expect(await readLastConsolidatedAt()).toBe(0)
const prior = await tryAcquireConsolidationLock()
expect(prior).toBe(0)
await rollbackConsolidationLock(prior!)
expect(await readLastConsolidatedAt()).toBe(0)
})
test('migrates a legacy successful PID stamp only when a new attempt is acquired', async () => {
const child = Bun.spawn([process.execPath, '--eval', ''], { stdout: 'ignore', stderr: 'ignore' })
await child.exited
const lock = join(directory, '.consolidate-lock')
const legacy = String(child.pid)
const previous = Date.now() - 48 * 3_600_000
await writeFile(lock, legacy)
await utimes(lock, previous / 1000, previous / 1000)
expect(await readLastConsolidatedAt()).toBeCloseTo(previous, 0)
expect(await readFile(lock, 'utf8')).toBe(legacy)
expect(await tryAcquireConsolidationLock()).toBeCloseTo(previous, 0)
expect(await readFile(lock, 'utf8')).toContain('auto-dream-v1')
await rollbackConsolidationLock(previous)
expect(await readLastConsolidatedAt()).toBeCloseTo(previous, 0)
})
test('manual consolidation records a completed timestamp', async () => {
await recordConsolidation()
expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000)
expect(await tryAcquireConsolidationLock()).toBeGreaterThan(Date.now() - 5000)
})
+43 -9
View File
@@ -1,4 +1,6 @@
// Lock file whose mtime IS lastConsolidatedAt. Body is the holder's PID.
// Lock file whose mtime is the consolidation timestamp. Legacy PID-only and
// empty bodies retain that meaning. New attempts explicitly record their
// in-progress state and prior timestamp, so crashes can be recovered safely.
//
// Lives inside the memory dir (getAutoMemPath) so it keys on git-root
// like memory does, and so it's writable even when the memory path comes
@@ -15,6 +17,18 @@ import { getProjectDir } from '../../utils/sessionStorage.js'
const LOCK_FILE = '.consolidate-lock'
type InProgressLock = { pid: number; priorMtime: number }
function parseInProgressLock(raw: string): InProgressLock | null {
const match = /^(\d+) auto-dream-v1 (\d+(?:\.\d+)?)$/.exec(raw.trim())
if (!match) return null
const pid = Number(match[1])
const priorMtime = Number(match[2])
return Number.isSafeInteger(pid) && pid > 1 && Number.isFinite(priorMtime)
? { pid, priorMtime }
: null
}
// Stale past this even if the PID is live (PID reuse guard).
const HOLDER_STALE_MS = 60 * 60 * 1000
@@ -24,11 +38,14 @@ function lockPath(): string {
/**
* mtime of the lock file = lastConsolidatedAt. 0 if absent.
* Per-turn cost: one stat.
* Only explicitly marked attempts can be identified as interrupted. A legacy
* dead PID may represent a successful run, so its timestamp stays authoritative.
*/
export async function readLastConsolidatedAt(): Promise<number> {
try {
const s = await stat(lockPath())
const [s, raw] = await Promise.all([stat(lockPath()), readFile(lockPath(), 'utf8')])
const attempt = parseInProgressLock(raw)
if (attempt && !isProcessRunning(attempt.pid)) return attempt.priorMtime
return s.mtimeMs
} catch {
return 0
@@ -36,10 +53,10 @@ export async function readLastConsolidatedAt(): Promise<number> {
}
/**
* Acquire: write PID → mtime = now. Returns the pre-acquire mtime
* Acquire: write a versioned PID + prior timestamp → mtime = now. Returns the pre-acquire mtime
* (for rollback), or null if blocked / lost a race.
*
* Success → do nothing. mtime stays at now.
* Success → completeConsolidationLock() clears PID, keeping mtime.
* Failure → rollbackConsolidationLock(priorMtime) rewinds mtime.
* Crash → mtime stuck, dead PID → next process reclaims.
*/
@@ -48,9 +65,12 @@ export async function tryAcquireConsolidationLock(): Promise<number | null> {
let mtimeMs: number | undefined
let holderPid: number | undefined
let priorMtime = 0
try {
const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
mtimeMs = s.mtimeMs
// Upgrade lazily on acquisition; never reinterpret a legacy PID as failure.
priorMtime = parseInProgressLock(raw)?.priorMtime ?? s.mtimeMs
const parsed = parseInt(raw.trim(), 10)
holderPid = Number.isFinite(parsed) ? parsed : undefined
} catch {
@@ -69,7 +89,8 @@ export async function tryAcquireConsolidationLock(): Promise<number | null> {
// Memory dir may not exist yet.
await mkdir(getAutoMemPath(), { recursive: true })
await writeFile(path, String(process.pid))
const record = `${process.pid} auto-dream-v1 ${priorMtime}`
await writeFile(path, record)
// Two reclaimers both write → last wins the PID. Loser bails on re-read.
let verify: string
@@ -78,9 +99,22 @@ export async function tryAcquireConsolidationLock(): Promise<number | null> {
} catch {
return null
}
if (parseInt(verify.trim(), 10) !== process.pid) return null
if (verify !== record) return null
return mtimeMs ?? 0
return priorMtime
}
/** Mark success without leaving a dead PID that looks like an interrupted run. */
export async function completeConsolidationLock(): Promise<void> {
const path = lockPath()
try {
const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
if (parseInProgressLock(raw)?.pid !== process.pid) return
await writeFile(path, '')
await utimes(path, s.atimeMs / 1000, s.mtimeMs / 1000)
} catch (e: unknown) {
logForDebugging(`[autoDream] completion stamp failed: ${(e as Error).message}`)
}
}
/**
@@ -131,7 +165,7 @@ export async function recordConsolidation(): Promise<void> {
try {
// Memory dir may not exist yet (manual /dream before any auto-trigger).
await mkdir(getAutoMemPath(), { recursive: true })
await writeFile(lockPath(), String(process.pid))
await writeFile(lockPath(), '')
} catch (e: unknown) {
logForDebugging(
`[autoDream] recordConsolidation write failed: ${(e as Error).message}`,