mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
fix(agents): sync agent settings changes to active session (#14205)
### What this PR does Before this PR: When modifying agent-level settings (permission mode, model, allowed tools, MCPs, instructions) via the Agent Settings popup, changes only took effect in newly created sessions. The currently active session kept using stale configuration, requiring users to create a new session to see the update. After this PR: Agent settings changes are automatically synced to all sessions belonging to that agent. Sessions where the user has intentionally customized a field are left untouched. Fixes #14197 ### Why we need it and why it was done in this way The agent–session architecture uses a snapshot model: sessions inherit agent configuration at creation time and then diverge independently. This is correct for session-level overrides, but when users edit agent-level settings (the "template"), they expect changes to apply to existing conversations. The sync is implemented in the backend `AgentService.updateAgent`: after updating the agent, it queries all sessions for that agent and compares each session field against the agent's **old** value. If they match, the session inherited the default and receives the new value. If they differ, the user customized that field on the session, so it's skipped. The frontend `useUpdateAgent` hook revalidates the active session's SWR cache so the UI reflects changes immediately. The following tradeoffs were made: - Comparison is done on serialized (JSON string) values for simplicity and correctness with complex fields like `configuration` and `allowed_tools`. - Sync failure is logged but does not fail the agent update. The following alternatives were considered: - Frontend-only sync (read active session from Redux, call updateSession API): rejected because it only syncs the active session and relies on ephemeral UI state. - Tracking a `customized_fields` set on each session: rejected as over-engineered; comparing against old values achieves the same result without schema changes. ### Breaking changes None. ### Special notes for your reviewer The synced fields are: `model`, `plan_model`, `small_model`, `allowed_tools`, `configuration`, `mcps`, `instructions`. Identity/workspace fields (`name`, `description`, `accessible_paths`) are intentionally excluded. ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [x] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note Agent settings changes (permission mode, model, tools, etc.) now take effect immediately in all sessions without requiring a new session. Sessions with user-customized fields are preserved. ``` --------- Signed-off-by: suyao <sy20010504@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import { AgentBaseSchema } from '@types'
|
||||
import { asc, count, desc, eq, sql } from 'drizzle-orm'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
|
||||
import { type AgentRow, agentsTable, type InsertAgentRow, sessionsTable } from '../database/schema'
|
||||
import type { AgentModelField } from '../errors'
|
||||
import { seedWorkspaceTemplates } from './cherryclaw/seedWorkspace'
|
||||
|
||||
@@ -382,10 +382,90 @@ export class AgentService extends BaseService {
|
||||
}
|
||||
|
||||
const database = await this.getDatabase()
|
||||
|
||||
// Read the raw agent row before updating — getAgent() normalizes allowed_tools
|
||||
// (legacy ID → canonical ID), but sessions store the original format. We need
|
||||
// the raw DB values so string comparison against sessions is accurate.
|
||||
const rawRows = await database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
|
||||
const rawOldAgent = rawRows[0]
|
||||
|
||||
await database.update(agentsTable).set(updateData).where(eq(agentsTable.id, id))
|
||||
|
||||
// Sync changed fields to all sessions that still match the agent's old values.
|
||||
// Sessions where the user has customized a field are left untouched.
|
||||
if (rawOldAgent) {
|
||||
await this.syncSettingsToSessions(database, id, rawOldAgent, serializedUpdates)
|
||||
}
|
||||
|
||||
return await this.getAgent(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync agent settings to all sessions that haven't been individually customized.
|
||||
*
|
||||
* For each changed field, we compare the session's current value against the agent's
|
||||
* OLD value (before update). If they match, the session inherited the default and
|
||||
* should receive the new value. If they differ, the user customized that field on
|
||||
* the session, so we skip it.
|
||||
*/
|
||||
private async syncSettingsToSessions(
|
||||
database: Awaited<ReturnType<typeof this.getDatabase>>,
|
||||
agentId: string,
|
||||
rawOldAgent: Record<string, unknown>,
|
||||
serializedUpdates: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const syncFields = ['model', 'plan_model', 'small_model', 'allowed_tools', 'configuration', 'mcps', 'instructions']
|
||||
|
||||
// rawOldAgent is already in DB-serialized form (JSON strings), so we can
|
||||
// compare directly against session rows without normalization mismatch.
|
||||
// Only sync fields that are present in the update AND actually changed.
|
||||
const changedFields = syncFields.filter((field) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(serializedUpdates, field)) return false
|
||||
return (serializedUpdates[field] ?? null) !== (rawOldAgent[field] ?? null)
|
||||
})
|
||||
if (changedFields.length === 0) return
|
||||
|
||||
try {
|
||||
const sessions = await database.select().from(sessionsTable).where(eq(sessionsTable.agent_id, agentId))
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await database.transaction(async (tx) => {
|
||||
for (const session of sessions) {
|
||||
const sessionUpdateData: Partial<Record<string, unknown>> = {}
|
||||
|
||||
for (const field of changedFields) {
|
||||
const oldAgentValue = rawOldAgent[field] ?? null
|
||||
const sessionValue = (session as Record<string, unknown>)[field] ?? null
|
||||
|
||||
// Only sync if session still has the agent's old value (not user-customized)
|
||||
if (oldAgentValue === sessionValue) {
|
||||
sessionUpdateData[field] = serializedUpdates[field] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(sessionUpdateData).length > 0) {
|
||||
sessionUpdateData.updated_at = now
|
||||
await tx.update(sessionsTable).set(sessionUpdateData).where(eq(sessionsTable.id, session.id))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Synced agent settings to sessions', {
|
||||
agentId,
|
||||
changedFields,
|
||||
sessionCount: sessions.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to sync agent settings to sessions', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async reorderAgents(orderedIds: string[]): Promise<void> {
|
||||
const database = await this.getDatabase()
|
||||
await database.transaction(async (tx) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import store from '@renderer/store'
|
||||
import type { AgentEntity, ListAgentsResponse, UpdateAgentForm } from '@renderer/types'
|
||||
import type { UpdateAgentBaseOptions, UpdateAgentFunction } from '@renderer/types/agent'
|
||||
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||
@@ -26,6 +27,18 @@ export const useUpdateAgent = () => {
|
||||
if (options?.showSuccessToast ?? true) {
|
||||
window.toast.success({ key: 'update-agent', title: t('common.update_success') })
|
||||
}
|
||||
|
||||
// Backend syncs agent settings to all sessions (skipping user-customized fields).
|
||||
// Revalidate the active session's SWR cache so the UI picks up changes immediately.
|
||||
// Other sessions refresh via SWR stale-while-revalidate when navigated to.
|
||||
// Using store.getState() instead of useSelector to avoid adding reactive deps to useCallback.
|
||||
const { activeSessionIdMap } = store.getState().runtime.chat
|
||||
const activeSessionId = activeSessionIdMap?.[form.id]
|
||||
if (activeSessionId) {
|
||||
const sessionKey = client.getSessionPaths(form.id).withId(activeSessionId)
|
||||
void mutate(sessionKey)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
|
||||
|
||||
Reference in New Issue
Block a user