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:
SuYao
2026-04-13 00:02:55 +08:00
committed by GitHub
parent e54cfe97ea
commit 51eed6e740
2 changed files with 94 additions and 1 deletions

View File

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

View File

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