fix(hololake): avoid no-op agent workspace writes

This commit is contained in:
冰朔 2026-08-12 07:18:46 +08:00
commit dc1a32a52b
6 changed files with 72 additions and 5 deletions

View file

@ -54,6 +54,8 @@ pnpm package:internal:windows
内测 Mac 包是 ad-hoc 签名、未公证Windows 包在没有商业证书时未做 Authenticode 签名。交付时必须明确说明,不能描述成正式签名发行版。 内测 Mac 包是 ad-hoc 签名、未公证Windows 包在没有商业证书时未做 Authenticode 签名。交付时必须明确说明,不能描述成正式签名发行版。
`tauri.local-candidate.conf.json` 的独立 bundle identifier 只保证候选应用可以与已安装版本并存,不隔离运行数据。当前架构仍从共享的 `com.tolaria.app` 命名空间读取设置、挂载知识库和 Agent 会话。需要无扰动验收时,必须用临时 `XDG_CONFIG_HOME` 启动候选,并在临时配置副本上验证;不能把“没有点击保存”当成“没有写盘”。
## Windows 构建恢复顺序 ## Windows 构建恢复顺序
1. `pnpm install --frozen-lockfile` 1. `pnpm install --frozen-lockfile`

View file

@ -4,7 +4,7 @@ import test from 'node:test'
const readJson = async path => JSON.parse(await readFile(path, 'utf8')) const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
test('local native candidate has an isolated macOS identity', async () => { test('local native candidate has an isolated macOS package identity', async () => {
const [base, candidate, packageJson] = await Promise.all([ const [base, candidate, packageJson] = await Promise.all([
readJson('src-tauri/tauri.conf.json'), readJson('src-tauri/tauri.conf.json'),
readJson('src-tauri/tauri.local-candidate.conf.json'), readJson('src-tauri/tauri.local-candidate.conf.json'),

View file

@ -360,6 +360,30 @@ describe('AiWorkspace', () => {
expect(screen.queryByRole('button', { name: 'Current work' })).toBeNull() expect(screen.queryByRole('button', { name: 'Current work' })).toBeNull()
}) })
it('does not rewrite unchanged conversation settings when the workspace opens', async () => {
const onConversationSettingsChange = vi.fn()
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[
{ id: 'live-chat', title: 'Current work', target_id: null, archived: false },
]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onConversationSettingsChange={onConversationSettingsChange}
/>,
)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Current work' })).toBeTruthy()
})
expect(onConversationSettingsChange).not.toHaveBeenCalled()
})
it('recovers stored sessions that no longer have conversation metadata', async () => { it('recovers stored sessions that no longer have conversation metadata', async () => {
setAiWorkspaceSessionMessages('orphaned-chat', [{ setAiWorkspaceSessionMessages('orphaned-chat', [{
userMessage: 'Recovered prompt', userMessage: 'Recovered prompt',

View file

@ -197,6 +197,20 @@ function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceCo
})) }))
} }
function conversationSettingsEqual(
current: AiWorkspaceConversationSetting[],
stored: AiWorkspaceConversationSetting[] | null | undefined,
): boolean {
if (!stored || current.length !== stored.length) return false
return current.every((conversation, index) => {
const persisted = stored[index]
return conversation.archived === (persisted.archived === true)
&& conversation.id === persisted.id
&& conversation.target_id === (persisted.target_id?.trim() || null)
&& conversation.title === persisted.title
})
}
export function activeConversationForState( export function activeConversationForState(
conversations: AiConversation[], conversations: AiConversation[],
activeId: ConversationId, activeId: ConversationId,
@ -370,10 +384,12 @@ function initialActiveId(conversations: AiConversation[], requestedId: Conversat
function useConversationSettingsPersistence({ function useConversationSettingsPersistence({
conversations, conversations,
onSettingsChange, onSettingsChange,
settings,
settingsReady, settingsReady,
}: { }: {
conversations: AiConversation[] conversations: AiConversation[]
onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
settings?: AiWorkspaceConversationSetting[] | null
settingsReady: boolean settingsReady: boolean
}) { }) {
const onSettingsChangeRef = useRef(onSettingsChange) const onSettingsChangeRef = useRef(onSettingsChange)
@ -384,8 +400,10 @@ function useConversationSettingsPersistence({
useEffect(() => { useEffect(() => {
if (!settingsReady) return if (!settingsReady) return
onSettingsChangeRef.current?.(conversationsToSettings(conversations)) const nextSettings = conversationsToSettings(conversations)
}, [conversations, settingsReady]) if (conversationSettingsEqual(nextSettings, settings)) return
onSettingsChangeRef.current?.(nextSettings)
}, [conversations, settings, settingsReady])
} }
function useTitleConversationFromAnswer(setConversations: SetConversations) { function useTitleConversationFromAnswer(setConversations: SetConversations) {
@ -490,7 +508,7 @@ export function useConversations({
const titleConversationFromAnswer = useTitleConversationFromAnswer(setConversations) const titleConversationFromAnswer = useTitleConversationFromAnswer(setConversations)
const updateDefaultConversationTargets = useUpdateDefaultConversationTargets(setConversations) const updateDefaultConversationTargets = useUpdateDefaultConversationTargets(setConversations)
useConversationSettingsPersistence({ conversations, onSettingsChange, settingsReady }) useConversationSettingsPersistence({ conversations, onSettingsChange, settings, settingsReady })
return { return {
activeId, addConversation, archiveConversation, closeConversation, conversations, deleteConversation, forkConversation, activeId, addConversation, archiveConversation, closeConversation, conversations, deleteConversation, forkConversation,

View file

@ -178,4 +178,28 @@ describe('aiWorkspaceSessionStore', () => {
expect(store.aiWorkspaceSessionSnapshot('native-chat').messages).toHaveLength(1) expect(store.aiWorkspaceSessionSnapshot('native-chat').messages).toHaveLength(1)
}) })
}) })
it('does not rewrite native history when hydration only reads it', async () => {
isTauriState.value = true
invokeMock.mockImplementation(async (command: string) => {
if (command === 'get_ai_workspace_sessions') {
return {
'native-chat': {
messages: [{ userMessage: 'Native', actions: [], response: 'History', id: 'native-message' }],
status: 'done',
},
}
}
return undefined
})
const store = await import('./aiWorkspaceSessionStore')
await vi.waitFor(() => {
expect(store.aiWorkspaceSessionSnapshot('native-chat').messages).toHaveLength(1)
})
await new Promise(resolve => setTimeout(resolve, 300))
expect(invokeMock).not.toHaveBeenCalledWith('save_ai_workspace_sessions', expect.anything())
})
}) })

View file

@ -160,7 +160,6 @@ async function syncFromNativeStorage(): Promise<void> {
const mergedSessions = mergeStoredSessions(sessionStore.getSnapshot(), nativeSessions) const mergedSessions = mergeStoredSessions(sessionStore.getSnapshot(), nativeSessions)
sessionStore.replaceSnapshot(mergedSessions) sessionStore.replaceSnapshot(mergedSessions)
sessionStore.writeStoredSnapshot(mergedSessions) sessionStore.writeStoredSnapshot(mergedSessions)
if (Object.keys(mergedSessions).length > 0) scheduleNativeSessionsWrite(mergedSessions)
} }
function ensureSessionStoreSync(): void { function ensureSessionStoreSync(): void {