mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(agents): override built-in agent model and effort
Built-in agents pin their own models — Explore and claude-code-guide run on Haiku, statusline-setup on Sonnet — and there was no way to change that. The only override mechanism was a same-named user agent, which replaces the definition wholesale: AGENT_SLUG_PATTERN is lowercase-only so `Explore` and `Plan` cannot even be created, and a replacement loses the runtime getSystemPrompt and the built-in tool privileges keyed off `source`. CLAUDE_CODE_SUBAGENT_MODEL is the only other lever and forces every subagent onto one model. Add `builtInAgentOverrides` to settings.json, carrying model and effort per agentType. It is applied in getBuiltInAgents(), the single choke point every consumer goes through, so the effective value reaches spawning, `/agents` and the desktop list without any of them knowing an override exists — and `source` stays `built-in`, preserving the prompt and tool privileges. Three constraints are load-bearing rather than stylistic: - `model: "inherit"` is a real value, not a reset. Built-in defaults differ per agent and per build, so clearing means deleting the field; the entry and then the key are removed once empty. - getSystemPrompt is never wrapped. serializeActiveAgent branches on `.length === 0`, and claude-code-guide declares one parameter while the others declare none, so a wrapper makes it destructure undefined. - The settings write is a read-modify-write inside the file lock. updateUserSettings is a top-level shallow merge and would replace the whole record, losing an update when two agents are changed in quick succession. strictPluginOnlyCustomization is enforced when resolving, not only when writing, since settings.json is user-editable by definition. Also surface edit and delete on the agent list rows. Both already existed but were reachable only after opening an agent's detail page. Rows become a div with the primary button and the actions as siblings rather than nested buttons, keep focus-within so the controls are not tabbable while invisible, and stay visible on touch where hover never fires. Built-in rows get the override entry point and no delete. The store's create/update/override paths now share runAgentMutation instead of a third hand-copy of the out-of-order guards; the existing store tests pass unchanged.
This commit is contained in:
@@ -67,6 +67,32 @@ describe('agentsApi', () => {
|
||||
expect(apiDeleteMock).toHaveBeenCalledWith('/api/agents/reviewer?scope=user')
|
||||
})
|
||||
|
||||
it('sends an override to the sub-resource, keeping explicit nulls', () => {
|
||||
// `null` is the wire form of "clear this field"; dropping it would silently
|
||||
// turn a reset into a no-op.
|
||||
agentsApi.setOverride('Explore', { cwd: '/workspace/one', model: null, effort: 'low' })
|
||||
|
||||
expect(apiPutMock).toHaveBeenCalledWith('/api/agents/Explore/override', {
|
||||
cwd: '/workspace/one',
|
||||
model: null,
|
||||
effort: 'low',
|
||||
})
|
||||
})
|
||||
|
||||
it('URL-encodes the override clear path and cwd', () => {
|
||||
agentsApi.clearOverride('reviewer/name?', '/workspace/project one')
|
||||
|
||||
expect(apiDeleteMock).toHaveBeenCalledWith(
|
||||
'/api/agents/reviewer%2Fname%3F/override?cwd=%2Fworkspace%2Fproject+one',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits an empty cwd from an override clear', () => {
|
||||
agentsApi.clearOverride('Explore')
|
||||
|
||||
expect(apiDeleteMock).toHaveBeenCalledWith('/api/agents/Explore/override')
|
||||
})
|
||||
|
||||
it('reloads the exact active session with the control timeout', () => {
|
||||
agentsApi.reload('session/one?')
|
||||
|
||||
|
||||
@@ -23,7 +23,24 @@ export type AgentDefinition = {
|
||||
target?: string
|
||||
overriddenBy?: AgentSource
|
||||
isActive: boolean
|
||||
/** The backing file can be rewritten. Never true for built-in agents. */
|
||||
editable?: boolean
|
||||
/** Built-in agents only: model and effort can be changed via setOverride. */
|
||||
overridable?: boolean
|
||||
/**
|
||||
* Built-in agents only: what this build ships with, so the UI can name and
|
||||
* restore the default. Never hardcode it — it varies per agent and per build.
|
||||
*/
|
||||
defaults?: { model?: string; effort?: string | number }
|
||||
/** Built-in agents only: the override currently in effect, if any. */
|
||||
override?: { model?: string; effort?: string | number; source: AgentSource }
|
||||
}
|
||||
|
||||
/** `null` clears that field; an omitted field is left unchanged. */
|
||||
export type AgentOverrideInput = {
|
||||
cwd?: string
|
||||
model?: string | null
|
||||
effort?: string | number | null
|
||||
}
|
||||
|
||||
export type AgentScope = 'user' | 'project'
|
||||
@@ -82,6 +99,19 @@ export const agentsApi = {
|
||||
if (target) query.set('target', target)
|
||||
return api.delete<void>(`/api/agents/${encodeURIComponent(name)}?${query.toString()}`)
|
||||
},
|
||||
setOverride: (name: string, input: AgentOverrideInput) =>
|
||||
api.put<AgentMutationResponse>(
|
||||
`/api/agents/${encodeURIComponent(name)}/override`,
|
||||
input,
|
||||
),
|
||||
clearOverride: (name: string, cwd?: string) => {
|
||||
const query = new URLSearchParams()
|
||||
if (cwd) query.set('cwd', cwd)
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return api.delete<AgentMutationResponse>(
|
||||
`/api/agents/${encodeURIComponent(name)}/override${suffix}`,
|
||||
)
|
||||
},
|
||||
reload: (sessionId: string) =>
|
||||
api.post<AgentReloadResponse>(
|
||||
`/api/agents/reload?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
|
||||
@@ -7,6 +7,8 @@ const apiCreateMock = vi.hoisted(() => vi.fn())
|
||||
const apiUpdateMock = vi.hoisted(() => vi.fn())
|
||||
const apiDeleteMock = vi.hoisted(() => vi.fn())
|
||||
const apiReloadMock = vi.hoisted(() => vi.fn())
|
||||
const apiSetOverrideMock = vi.hoisted(() => vi.fn())
|
||||
const apiClearOverrideMock = vi.hoisted(() => vi.fn())
|
||||
const recentProjectsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../api/agents', async (importOriginal) => {
|
||||
@@ -19,6 +21,8 @@ vi.mock('../../api/agents', async (importOriginal) => {
|
||||
update: apiUpdateMock,
|
||||
delete: apiDeleteMock,
|
||||
reload: apiReloadMock,
|
||||
setOverride: apiSetOverrideMock,
|
||||
clearOverride: apiClearOverrideMock,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -65,6 +69,23 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
function makeBuiltInAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
return {
|
||||
agentType: 'Explore',
|
||||
description: 'Explore the codebase',
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
isActive: true,
|
||||
// Built-ins are never file-editable; only model and effort can change.
|
||||
editable: false,
|
||||
overridable: true,
|
||||
defaults: { model: 'haiku' },
|
||||
model: 'haiku',
|
||||
modelDisplay: 'haiku',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setProjectSession(cwd?: string) {
|
||||
useSessionStore.setState({
|
||||
sessions: cwd ? [{
|
||||
@@ -612,6 +633,204 @@ describe('AgentManager', () => {
|
||||
expect(screen.getByRole('dialog', { name: 'Create Agent' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers edit and delete on the row itself without nesting buttons', async () => {
|
||||
const agent = makeAgent()
|
||||
await renderManager({ activeAgents: [agent], allAgents: [agent] })
|
||||
|
||||
const editButton = screen.getByRole('button', { name: 'Edit code_reviewer' })
|
||||
expect(editButton).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete code_reviewer' })).toBeInTheDocument()
|
||||
|
||||
// The structural assertion is the one that matters: nested buttons still
|
||||
// render and still fire in jsdom, so behaviour alone cannot catch them.
|
||||
const row = editButton.closest('div.group')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.querySelector('button button')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the row body clickable now that it is no longer the outer element', async () => {
|
||||
const agent = makeAgent()
|
||||
await renderManager({ activeAgents: [agent], allAgents: [agent] })
|
||||
|
||||
fireEvent.click(screen.getByText('code_reviewer'))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Back to list' })).toBeInTheDocument()
|
||||
expect(useAgentStore.getState().selectedAgent?.agentType).toBe('code_reviewer')
|
||||
})
|
||||
|
||||
it('deletes straight from the row with that row exact target', async () => {
|
||||
const agent = makeAgent({ source: 'projectSettings' })
|
||||
apiListMock
|
||||
.mockResolvedValueOnce({ activeAgents: [agent], allAgents: [agent] })
|
||||
.mockResolvedValueOnce(EMPTY_RESPONSE)
|
||||
apiDeleteMock.mockResolvedValue(undefined)
|
||||
|
||||
render(<AgentManager />)
|
||||
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete code_reviewer' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Agent' }))
|
||||
|
||||
await waitFor(() => expect(apiDeleteMock).toHaveBeenCalledWith(
|
||||
'code_reviewer',
|
||||
'project',
|
||||
'/workspace/project',
|
||||
'nested/custom-agent-file.md',
|
||||
))
|
||||
})
|
||||
|
||||
it('offers no row actions on sources that can be neither edited nor overridden', async () => {
|
||||
const plugin = makeAgent({
|
||||
agentType: 'plugin_agent',
|
||||
source: 'plugin',
|
||||
editable: false,
|
||||
target: undefined,
|
||||
})
|
||||
await renderManager({ activeAgents: [plugin], allAgents: [plugin] })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit plugin_agent' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Delete plugin_agent' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Adjust the model/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a built-in row model adjustment but never a delete', async () => {
|
||||
const builtIn = makeBuiltInAgent()
|
||||
await renderManager({ activeAgents: [builtIn], allAgents: [builtIn] })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete Explore' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Edit Explore' })).toBeNull()
|
||||
})
|
||||
|
||||
it('separates the built-in default from inherit and sends null for the default', async () => {
|
||||
const builtIn = makeBuiltInAgent()
|
||||
await renderManager({ activeAgents: [builtIn], allAgents: [builtIn] })
|
||||
apiSetOverrideMock.mockResolvedValue({ agent: builtIn })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [builtIn], allAgents: [builtIn] })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
)
|
||||
|
||||
// Both entries must exist. For Explore the shipped default is haiku while
|
||||
// inherit means "follow the main session" — collapsing them would make
|
||||
// inherit unreachable, and the default label is read from the server.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Model' }))
|
||||
expect(screen.getByRole('option', { name: 'Built-in default (haiku)' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'Inherit from parent' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Built-in default (haiku)' }))
|
||||
|
||||
chooseAgentSelect('Reasoning effort', 'high')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
// `null`, never the literal 'haiku': writing today's default into
|
||||
// settings.json would freeze it there forever.
|
||||
await waitFor(() => expect(apiSetOverrideMock).toHaveBeenCalledWith('Explore', {
|
||||
cwd: '/workspace/project',
|
||||
model: null,
|
||||
effort: 'high',
|
||||
}))
|
||||
})
|
||||
|
||||
it('sends inherit as a real value when the user picks it', async () => {
|
||||
const builtIn = makeBuiltInAgent()
|
||||
await renderManager({ activeAgents: [builtIn], allAgents: [builtIn] })
|
||||
apiSetOverrideMock.mockResolvedValue({ agent: builtIn })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [builtIn], allAgents: [builtIn] })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
)
|
||||
chooseAgentSelect('Model', 'Inherit from parent')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(apiSetOverrideMock).toHaveBeenCalledWith('Explore', {
|
||||
cwd: '/workspace/project',
|
||||
model: 'inherit',
|
||||
effort: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('resets a built-in through the server instead of writing the default back', async () => {
|
||||
const overridden = makeBuiltInAgent({
|
||||
model: 'sonnet',
|
||||
modelDisplay: 'sonnet',
|
||||
override: { model: 'sonnet', source: 'userSettings' },
|
||||
})
|
||||
await renderManager({ activeAgents: [overridden], allAgents: [overridden] })
|
||||
apiClearOverrideMock.mockResolvedValue({ agent: makeBuiltInAgent() })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [overridden], allAgents: [overridden] })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to built-in default' }))
|
||||
|
||||
await waitFor(() => expect(apiClearOverrideMock).toHaveBeenCalledWith(
|
||||
'Explore',
|
||||
'/workspace/project',
|
||||
))
|
||||
// Reset must clear the setting, not write the current default back as a
|
||||
// value — that would pin today's default into settings.json permanently.
|
||||
expect(apiSetOverrideMock).not.toHaveBeenCalled()
|
||||
// A running session caches agent definitions, so the write alone is not
|
||||
// enough for the change to take effect.
|
||||
expect(apiReloadMock).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('locks the controls when the override comes from managed settings', async () => {
|
||||
const managed = makeBuiltInAgent({
|
||||
model: 'opus',
|
||||
override: { model: 'opus', source: 'policySettings' },
|
||||
})
|
||||
await renderManager({ activeAgents: [managed], allAgents: [managed] })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Model' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Reasoning effort' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
// Resetting would write to the user file, which cannot win over a policy.
|
||||
expect(screen.queryByRole('button', { name: 'Reset to built-in default' })).toBeNull()
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Set by Managed settings and not editable here.',
|
||||
)
|
||||
})
|
||||
|
||||
it('warns that a shadowed built-in will not take effect yet', async () => {
|
||||
// Editing a built-in that a same-named user agent shadows would look like
|
||||
// it worked and change nothing at spawn time.
|
||||
const shadowed = makeBuiltInAgent({ overriddenBy: 'userSettings', isActive: false })
|
||||
await renderManager({ activeAgents: [], allAgents: [shadowed] })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'An agent of the same name from User is active',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the override modal open when saving fails', async () => {
|
||||
const builtIn = makeBuiltInAgent()
|
||||
await renderManager({ activeAgents: [builtIn], allAgents: [builtIn] })
|
||||
apiSetOverrideMock.mockRejectedValue(new Error('AGENT_CUSTOMIZATION_LOCKED'))
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
|
||||
)
|
||||
chooseAgentSelect('Model', 'sonnet')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to save the override')
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('AGENT_CUSTOMIZATION_LOCKED')
|
||||
expect(screen.getByRole('dialog', { name: 'Adjust built-in agent' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes load failures without exposing raw server errors', async () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
apiListMock.mockRejectedValue(new Error('HTTP 500: internal agent path leaked'))
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ErrorState } from '@/components/ui/ErrorState'
|
||||
import { LoadingState } from '@/components/ui/LoadingState'
|
||||
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
|
||||
import { Dropdown } from '@/components/ui/Dropdown'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { SearchField } from '@/components/ui/SearchField'
|
||||
@@ -71,6 +72,11 @@ const AGENT_SOURCE_ORDER: AgentSource[] = [
|
||||
|
||||
const BUILT_IN_MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
|
||||
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
|
||||
/**
|
||||
* "Use whatever this build ships" in the built-in override modal, submitted as
|
||||
* `null`. Distinct from `inherit`, which is itself a persistable choice.
|
||||
*/
|
||||
const DEFAULT_CHOICE = '__default__'
|
||||
const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/
|
||||
type ToolAccessMode = 'inherit' | 'none' | 'custom'
|
||||
type ToolCategory = 'readSearch' | 'modify' | 'execute' | 'workflow' | 'other'
|
||||
@@ -123,6 +129,7 @@ export function AgentManager() {
|
||||
const t = useTranslation()
|
||||
const [formState, setFormState] = useState<{ mode: 'create' | 'edit'; agent?: AgentDefinition } | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AgentDefinition | null>(null)
|
||||
const [overrideTarget, setOverrideTarget] = useState<AgentDefinition | null>(null)
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
const currentWorkDir = getSessionBrowsablePath(activeSession)
|
||||
@@ -185,6 +192,7 @@ export function AgentManager() {
|
||||
onBack={handleAgentBack}
|
||||
onEdit={() => setFormState({ mode: 'edit', agent: selectedAgent })}
|
||||
onDelete={() => setDeleteTarget(selectedAgent)}
|
||||
onOverride={() => setOverrideTarget(selectedAgent)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -260,11 +268,18 @@ export function AgentManager() {
|
||||
</div>
|
||||
<div className="flex flex-col p-2">
|
||||
{group.map((agent, index) => (
|
||||
<button
|
||||
// A row is a div, not a button: the actions on the
|
||||
// right have to be siblings of the primary control,
|
||||
// never nested inside it.
|
||||
<div
|
||||
key={`${agent.source}-${agent.agentType}-${agent.target ?? agent.baseDir ?? index}`}
|
||||
onClick={() => selectAgent(agent, 'agents')}
|
||||
className="group rounded-[var(--radius-xl)] border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
|
||||
className="group flex items-start gap-1 rounded-[var(--radius-xl)] border border-transparent px-3 py-3 transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectAgent(agent, 'agents')}
|
||||
className="min-w-0 flex-1 rounded-[var(--radius-lg)] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Bot size={18} className="mt-0.5 shrink-0" style={{ color: getAgentDotColor(agent.color) }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -298,7 +313,14 @@ export function AgentManager() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
<AgentRowActions
|
||||
agent={agent}
|
||||
onEdit={() => setFormState({ mode: 'edit', agent })}
|
||||
onDelete={() => setDeleteTarget(agent)}
|
||||
onOverride={() => setOverrideTarget(agent)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -326,6 +348,14 @@ export function AgentManager() {
|
||||
sessionId={contextSessionId}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
{overrideTarget && (
|
||||
<BuiltInAgentOverrideModal
|
||||
agent={overrideTarget}
|
||||
cwd={agentContextPath}
|
||||
sessionId={contextSessionId}
|
||||
onClose={() => setOverrideTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -335,11 +365,13 @@ function AgentDetailView({
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOverride,
|
||||
}: {
|
||||
agent: AgentDefinition
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onOverride: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const sourceLabel = t(`settings.agents.source.${agent.source}`)
|
||||
@@ -362,7 +394,16 @@ function AgentDetailView({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<MetaPill><LockKeyhole size={11} /> {t('settings.agents.readOnly')}</MetaPill>
|
||||
<div className="flex items-center gap-2">
|
||||
{agent.overridable && (
|
||||
<Button variant="secondary" size="sm" icon={<Bolt size={14} />} onClick={onOverride}>
|
||||
{t('settings.agents.override')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Kept alongside the button: the prompt and tools really are fixed,
|
||||
and only the model and effort are not. */}
|
||||
<MetaPill><LockKeyhole size={11} /> {t('settings.agents.readOnly')}</MetaPill>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -938,6 +979,272 @@ function AgentDeleteDialog({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Model/effort editor for a built-in agent.
|
||||
*
|
||||
* Deliberately not `AgentFormModal` with a flag. That component exists to build
|
||||
* an `AgentMutationInput` whose name, description and system prompt are all
|
||||
* required, and none of those apply here; threading a variant through its
|
||||
* render branches and its payload-construction chain would put the riskiest
|
||||
* code in this file on a second, unrelated path.
|
||||
*/
|
||||
function BuiltInAgentOverrideModal({
|
||||
agent,
|
||||
cwd,
|
||||
sessionId,
|
||||
onClose,
|
||||
}: {
|
||||
agent: AgentDefinition
|
||||
cwd?: string
|
||||
sessionId?: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const setAgentOverride = useAgentStore((state) => state.setAgentOverride)
|
||||
const clearAgentOverride = useAgentStore((state) => state.clearAgentOverride)
|
||||
const isMutating = useAgentStore((state) => state.isMutating)
|
||||
|
||||
const defaultModel = agent.defaults?.model
|
||||
const defaultEffort = agent.defaults?.effort
|
||||
const overrideSource = agent.override?.source
|
||||
// A managed or project-level override cannot be edited from the user file
|
||||
// this modal writes to, so saying so beats a write that silently loses.
|
||||
const isManaged = overrideSource !== undefined && overrideSource !== 'userSettings'
|
||||
|
||||
const initialModel = agent.override?.model
|
||||
const initialEffort = agent.override?.effort
|
||||
const [modelChoice, setModelChoice] = useState(
|
||||
initialModel === undefined
|
||||
? DEFAULT_CHOICE
|
||||
: initialModel === 'inherit' || BUILT_IN_MODELS.includes(initialModel as typeof BUILT_IN_MODELS[number])
|
||||
? initialModel
|
||||
: 'custom',
|
||||
)
|
||||
const [customModel, setCustomModel] = useState(
|
||||
modelChoice === 'custom' ? (initialModel ?? '') : '',
|
||||
)
|
||||
const [effort, setEffort] = useState(
|
||||
initialEffort === undefined ? DEFAULT_CHOICE : String(initialEffort),
|
||||
)
|
||||
const [customModelError, setCustomModelError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const describeDefault = (value: string | number | undefined) =>
|
||||
value === undefined
|
||||
? t('settings.agents.overrideDefaultNone')
|
||||
: t('settings.agents.overrideDefault', { value: String(value) })
|
||||
|
||||
const handleSave = async () => {
|
||||
if (modelChoice === 'custom' && !customModel.trim()) {
|
||||
setCustomModelError(t('settings.agents.form.customModelRequired'))
|
||||
return
|
||||
}
|
||||
setCustomModelError(null)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await setAgentOverride(
|
||||
agent.agentType,
|
||||
{
|
||||
...(cwd ? { cwd } : {}),
|
||||
// `null` clears the override so the shipped default applies again.
|
||||
// Never send the default's literal value: that would freeze today's
|
||||
// default into the user's settings file forever.
|
||||
model:
|
||||
modelChoice === DEFAULT_CHOICE
|
||||
? null
|
||||
: modelChoice === 'custom'
|
||||
? customModel.trim()
|
||||
: modelChoice,
|
||||
effort: effort === DEFAULT_CHOICE ? null : effort,
|
||||
},
|
||||
sessionId,
|
||||
)
|
||||
onClose()
|
||||
} catch {
|
||||
setSubmitError(t('settings.agents.overrideSaveError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await clearAgentOverride(agent.agentType, cwd, sessionId)
|
||||
onClose()
|
||||
} catch {
|
||||
setSubmitError(t('settings.agents.overrideResetError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={isMutating ? () => {} : onClose}
|
||||
title={t('settings.agents.overrideTitle')}
|
||||
width={520}
|
||||
footer={(
|
||||
<>
|
||||
{agent.override && !isManaged && (
|
||||
<Button variant="ghost" onClick={() => void handleReset()} disabled={isMutating}>
|
||||
{t('settings.agents.overrideReset')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onClose} disabled={isMutating}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => void handleSave()} disabled={isMutating || isManaged}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{agent.agentType}
|
||||
</span>
|
||||
<MetaPill>{t('settings.agents.source.built-in')}</MetaPill>
|
||||
{agent.override && <MetaPill>{t('settings.agents.overrideBadge')}</MetaPill>}
|
||||
</div>
|
||||
|
||||
{agent.overriddenBy && (
|
||||
// Editing a built-in that a same-named user agent shadows would look
|
||||
// like it worked and change nothing at spawn time.
|
||||
<p role="status" className="rounded-[var(--radius-lg)] bg-[var(--color-warning-container)] px-3 py-2 text-xs leading-5 text-[var(--color-text-primary)]">
|
||||
{t('settings.agents.overrideShadowed', {
|
||||
source: t(`settings.agents.source.${agent.overriddenBy}`),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{isManaged && (
|
||||
<p role="status" className="rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{t('settings.agents.overrideManaged', {
|
||||
source: t(`settings.agents.source.${overrideSource}`),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t('settings.agents.form.model')}>
|
||||
<AgentSelect
|
||||
label={t('settings.agents.form.model')}
|
||||
value={modelChoice}
|
||||
onChange={setModelChoice}
|
||||
disabled={isManaged}
|
||||
items={[
|
||||
// Two separate entries on purpose. For Explore the built-in
|
||||
// default is haiku while "inherit" means follow the main
|
||||
// session — collapsing them makes inherit unreachable.
|
||||
{ value: DEFAULT_CHOICE, label: describeDefault(defaultModel) },
|
||||
{ value: 'inherit', label: t('settings.agents.form.inherit') },
|
||||
...BUILT_IN_MODELS.map((model) => ({ value: model, label: model })),
|
||||
{ value: 'custom', label: t('settings.agents.form.customModel') },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('settings.agents.form.effort')}>
|
||||
<AgentSelect
|
||||
label={t('settings.agents.form.effort')}
|
||||
value={effort}
|
||||
onChange={setEffort}
|
||||
disabled={isManaged}
|
||||
items={[
|
||||
// No "inherit" entry: effort has no such value, omitting it is
|
||||
// what inherits.
|
||||
{ value: DEFAULT_CHOICE, label: describeDefault(defaultEffort) },
|
||||
...EFFORTS.map((value) => ({ value, label: value })),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{modelChoice === 'custom' && (
|
||||
<Input
|
||||
label={t('settings.agents.form.customModelId')}
|
||||
required
|
||||
value={customModel}
|
||||
error={customModelError ?? undefined}
|
||||
disabled={isManaged}
|
||||
onChange={(event) => setCustomModel(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.overrideHint')}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.agents.overrideScopeHint')}
|
||||
</p>
|
||||
{submitError && <p role="alert" className="text-sm text-[var(--color-error)]">{submitError}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row actions, rendered as a sibling of the row's primary button.
|
||||
*
|
||||
* Hidden until the row is hovered, but `focus-within` is not optional: without
|
||||
* it a keyboard user tabs onto a control they cannot see. The fade lives on
|
||||
* this wrapper rather than on the buttons because IconButton already sets
|
||||
* `transition-colors`, and a second transition utility on the same element
|
||||
* resolves by stylesheet order instead of by intent.
|
||||
*/
|
||||
function AgentRowActions({
|
||||
agent,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOverride,
|
||||
}: {
|
||||
agent: AgentDefinition
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onOverride: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const editable = isEditableAgent(agent)
|
||||
const overridable = agent.overridable === true
|
||||
|
||||
if (!editable && !overridable) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
// Marked for the touch stylesheet: hover-only affordances are
|
||||
// permanently invisible on a touchscreen.
|
||||
data-agent-row-actions
|
||||
className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
{editable ? (
|
||||
<>
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="muted"
|
||||
icon={<Pencil size={14} />}
|
||||
label={t('settings.agents.rowEdit', { name: agent.agentType })}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="muted"
|
||||
// A delete icon that sits red at rest reads as an error state.
|
||||
hoverTone="danger"
|
||||
icon={<Trash2 size={14} />}
|
||||
label={t('settings.agents.rowDelete', { name: agent.agentType })}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Built-ins get model/effort only — their file is never rewritten, so
|
||||
// there is deliberately no delete here.
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="muted"
|
||||
icon={<Bolt size={14} />}
|
||||
label={t('settings.agents.rowOverride', { name: agent.agentType })}
|
||||
onClick={onOverride}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function isEditableAgent(agent: AgentDefinition) {
|
||||
return agent.editable === true && getEditableScope(agent) !== null
|
||||
}
|
||||
@@ -1011,11 +1318,13 @@ function AgentSelect<T extends string>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string
|
||||
items: Array<{ value: T; label: string; icon?: ReactNode }>
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const selected = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
@@ -1031,7 +1340,8 @@ function AgentSelect<T extends string>({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-full items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||
disabled={disabled}
|
||||
className="flex h-10 w-full items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{selected?.icon && <span className="shrink-0">{selected.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{selected?.label ?? value}</span>
|
||||
|
||||
@@ -56,6 +56,34 @@ describe('useTranslation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('localizes built-in agent overrides and row actions in every registered locale', () => {
|
||||
for (const locale of ['en', 'zh', 'zh-TW', 'jp', 'kr'] as const) {
|
||||
for (const key of [
|
||||
'settings.agents.override',
|
||||
'settings.agents.overrideTitle',
|
||||
'settings.agents.overrideHint',
|
||||
'settings.agents.overrideScopeHint',
|
||||
'settings.agents.overrideDefaultNone',
|
||||
'settings.agents.overrideReset',
|
||||
'settings.agents.overrideBadge',
|
||||
'settings.agents.overrideSaveError',
|
||||
'settings.agents.overrideResetError',
|
||||
] as const) {
|
||||
expect(translate(locale, key), `${locale} is missing ${key}`).not.toBe(key)
|
||||
}
|
||||
|
||||
// The parameterized ones have to actually substitute: a locale that drops
|
||||
// the placeholder would render "Edit " with no agent name, leaving every
|
||||
// row action with the same accessible name.
|
||||
expect(translate(locale, 'settings.agents.rowEdit', { name: 'Explore' })).toContain('Explore')
|
||||
expect(translate(locale, 'settings.agents.rowDelete', { name: 'Explore' })).toContain('Explore')
|
||||
expect(translate(locale, 'settings.agents.rowOverride', { name: 'Explore' })).toContain('Explore')
|
||||
expect(translate(locale, 'settings.agents.overrideDefault', { value: 'haiku' })).toContain('haiku')
|
||||
expect(translate(locale, 'settings.agents.overrideManaged', { source: 'X' })).toContain('X')
|
||||
expect(translate(locale, 'settings.agents.overrideShadowed', { source: 'X' })).toContain('X')
|
||||
}
|
||||
})
|
||||
|
||||
it('describes exactly the standard ~/.claude mode and an external custom mode', () => {
|
||||
expect(translate('en', 'settings.general.storageSystemDescription')).toContain('~/.claude')
|
||||
expect(translate('zh', 'settings.general.storageSystemDescription')).toContain('~/.claude')
|
||||
|
||||
@@ -1151,6 +1151,21 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'settings.agents.form.color': 'Color',
|
||||
'settings.agents.form.noColor': 'Default',
|
||||
'settings.agents.form.saveError': 'Failed to save agent',
|
||||
'settings.agents.rowEdit': 'Edit {name}',
|
||||
'settings.agents.rowDelete': 'Delete {name}',
|
||||
'settings.agents.rowOverride': 'Adjust the model and effort for {name}',
|
||||
'settings.agents.override': 'Adjust model',
|
||||
'settings.agents.overrideTitle': 'Adjust built-in agent',
|
||||
'settings.agents.overrideHint': 'Only the model and effort can be changed. The system prompt, tools and color are fixed by Claude Code.',
|
||||
'settings.agents.overrideScopeHint': 'Saved to your user settings (~/.claude/settings.json) and applied to every project.',
|
||||
'settings.agents.overrideDefault': 'Built-in default ({value})',
|
||||
'settings.agents.overrideDefaultNone': 'Built-in default',
|
||||
'settings.agents.overrideReset': 'Reset to built-in default',
|
||||
'settings.agents.overrideBadge': 'Overridden',
|
||||
'settings.agents.overrideManaged': 'Set by {source} settings and not editable here.',
|
||||
'settings.agents.overrideShadowed': 'An agent of the same name from {source} is active, so changes here will not take effect yet.',
|
||||
'settings.agents.overrideSaveError': 'Failed to save the override',
|
||||
'settings.agents.overrideResetError': 'Failed to reset to the built-in default',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': 'Installed Skills',
|
||||
|
||||
@@ -1153,6 +1153,21 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.color': '色',
|
||||
'settings.agents.form.noColor': 'デフォルト',
|
||||
'settings.agents.form.saveError': 'エージェントの保存に失敗しました',
|
||||
'settings.agents.rowEdit': '{name} を編集',
|
||||
'settings.agents.rowDelete': '{name} を削除',
|
||||
'settings.agents.rowOverride': '{name} のモデルと推論強度を調整',
|
||||
'settings.agents.override': 'モデルを調整',
|
||||
'settings.agents.overrideTitle': '組み込みエージェントの調整',
|
||||
'settings.agents.overrideHint': '変更できるのはモデルと推論強度だけです。システムプロンプト、ツール、色は Claude Code が固定します。',
|
||||
'settings.agents.overrideScopeHint': 'ユーザー設定(~/.claude/settings.json)に保存され、すべてのプロジェクトに適用されます。',
|
||||
'settings.agents.overrideDefault': '組み込みの既定値({value})',
|
||||
'settings.agents.overrideDefaultNone': '組み込みの既定値',
|
||||
'settings.agents.overrideReset': '組み込みの既定値に戻す',
|
||||
'settings.agents.overrideBadge': '上書き中',
|
||||
'settings.agents.overrideManaged': '{source}の設定で指定されているため、ここでは変更できません。',
|
||||
'settings.agents.overrideShadowed': '現在は{source}の同名エージェントが有効なため、ここでの変更はまだ反映されません。',
|
||||
'settings.agents.overrideSaveError': '上書きの保存に失敗しました',
|
||||
'settings.agents.overrideResetError': '既定値への復元に失敗しました',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': 'インストール済みスキル',
|
||||
|
||||
@@ -1153,6 +1153,21 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.color': '색상',
|
||||
'settings.agents.form.noColor': '기본값',
|
||||
'settings.agents.form.saveError': '에이전트를 저장하지 못했습니다',
|
||||
'settings.agents.rowEdit': '{name} 편집',
|
||||
'settings.agents.rowDelete': '{name} 삭제',
|
||||
'settings.agents.rowOverride': '{name}의 모델과 추론 강도 조정',
|
||||
'settings.agents.override': '모델 조정',
|
||||
'settings.agents.overrideTitle': '내장 에이전트 조정',
|
||||
'settings.agents.overrideHint': '모델과 추론 강도만 변경할 수 있습니다. 시스템 프롬프트, 도구, 색상은 Claude Code가 고정합니다.',
|
||||
'settings.agents.overrideScopeHint': '사용자 설정(~/.claude/settings.json)에 저장되며 모든 프로젝트에 적용됩니다.',
|
||||
'settings.agents.overrideDefault': '내장 기본값({value})',
|
||||
'settings.agents.overrideDefaultNone': '내장 기본값',
|
||||
'settings.agents.overrideReset': '내장 기본값으로 되돌리기',
|
||||
'settings.agents.overrideBadge': '재정의됨',
|
||||
'settings.agents.overrideManaged': '{source} 설정에서 지정되어 여기서는 변경할 수 없습니다.',
|
||||
'settings.agents.overrideShadowed': '현재 {source}의 같은 이름 에이전트가 적용 중이라 여기서 변경해도 아직 반영되지 않습니다.',
|
||||
'settings.agents.overrideSaveError': '재정의를 저장하지 못했습니다',
|
||||
'settings.agents.overrideResetError': '기본값으로 되돌리지 못했습니다',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': '설치된 스킬',
|
||||
|
||||
@@ -1152,6 +1152,21 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.color': '顏色',
|
||||
'settings.agents.form.noColor': '預設',
|
||||
'settings.agents.form.saveError': '儲存 Agent 失敗',
|
||||
'settings.agents.rowEdit': '編輯 {name}',
|
||||
'settings.agents.rowDelete': '刪除 {name}',
|
||||
'settings.agents.rowOverride': '調整 {name} 的模型與思考強度',
|
||||
'settings.agents.override': '調整模型',
|
||||
'settings.agents.overrideTitle': '調整內建 Agent',
|
||||
'settings.agents.overrideHint': '只能修改模型與思考強度。系統提示詞、工具和顏色由 Claude Code 固定。',
|
||||
'settings.agents.overrideScopeHint': '儲存到使用者設定(~/.claude/settings.json),對所有專案生效。',
|
||||
'settings.agents.overrideDefault': '內建預設({value})',
|
||||
'settings.agents.overrideDefaultNone': '內建預設',
|
||||
'settings.agents.overrideReset': '恢復內建預設',
|
||||
'settings.agents.overrideBadge': '已覆寫',
|
||||
'settings.agents.overrideManaged': '由{source}設定指定,無法在此修改。',
|
||||
'settings.agents.overrideShadowed': '目前生效的是{source}的同名 Agent,此處的修改暫時不會生效。',
|
||||
'settings.agents.overrideSaveError': '儲存覆寫失敗',
|
||||
'settings.agents.overrideResetError': '恢復預設失敗',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': '已安裝技能',
|
||||
|
||||
@@ -1152,6 +1152,21 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.agents.form.color': '颜色',
|
||||
'settings.agents.form.noColor': '默认',
|
||||
'settings.agents.form.saveError': '保存 Agent 失败',
|
||||
'settings.agents.rowEdit': '编辑 {name}',
|
||||
'settings.agents.rowDelete': '删除 {name}',
|
||||
'settings.agents.rowOverride': '调整 {name} 的模型和思考强度',
|
||||
'settings.agents.override': '调整模型',
|
||||
'settings.agents.overrideTitle': '调整内置 Agent',
|
||||
'settings.agents.overrideHint': '只能修改模型和思考强度。系统提示词、工具和颜色由 Claude Code 固定。',
|
||||
'settings.agents.overrideScopeHint': '保存到用户设置(~/.claude/settings.json),对所有项目生效。',
|
||||
'settings.agents.overrideDefault': '内置默认({value})',
|
||||
'settings.agents.overrideDefaultNone': '内置默认',
|
||||
'settings.agents.overrideReset': '恢复内置默认',
|
||||
'settings.agents.overrideBadge': '已覆盖',
|
||||
'settings.agents.overrideManaged': '由{source}设置指定,无法在此修改。',
|
||||
'settings.agents.overrideShadowed': '当前生效的是{source}的同名 Agent,此处的修改暂不会生效。',
|
||||
'settings.agents.overrideSaveError': '保存覆盖失败',
|
||||
'settings.agents.overrideResetError': '恢复默认失败',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': '已安装技能',
|
||||
|
||||
@@ -5,6 +5,8 @@ const apiCreateMock = vi.hoisted(() => vi.fn())
|
||||
const apiUpdateMock = vi.hoisted(() => vi.fn())
|
||||
const apiDeleteMock = vi.hoisted(() => vi.fn())
|
||||
const apiReloadMock = vi.hoisted(() => vi.fn())
|
||||
const apiSetOverrideMock = vi.hoisted(() => vi.fn())
|
||||
const apiClearOverrideMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: {
|
||||
@@ -13,6 +15,8 @@ vi.mock('../api/agents', () => ({
|
||||
update: apiUpdateMock,
|
||||
delete: apiDeleteMock,
|
||||
reload: apiReloadMock,
|
||||
setOverride: apiSetOverrideMock,
|
||||
clearOverride: apiClearOverrideMock,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -30,6 +34,22 @@ function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
function makeBuiltInAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
return {
|
||||
agentType: 'Explore',
|
||||
description: 'Explore the codebase',
|
||||
source: 'built-in',
|
||||
isActive: true,
|
||||
// Built-ins are never file-editable; only model and effort can change.
|
||||
editable: false,
|
||||
overridable: true,
|
||||
defaults: { model: 'haiku' },
|
||||
model: 'haiku',
|
||||
modelDisplay: 'haiku',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeInput(overrides: Partial<AgentMutationInput> = {}): AgentMutationInput {
|
||||
return {
|
||||
scope: 'user',
|
||||
@@ -769,6 +789,144 @@ describe('agentStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rebinds the selected built-in agent after an override', async () => {
|
||||
const before = makeBuiltInAgent()
|
||||
const after = makeBuiltInAgent({
|
||||
model: 'sonnet',
|
||||
modelDisplay: 'sonnet',
|
||||
override: { model: 'sonnet', source: 'userSettings' },
|
||||
})
|
||||
useAgentStore.setState({ selectedAgent: before, allAgents: [before], activeAgents: [before] })
|
||||
apiSetOverrideMock.mockResolvedValue({ agent: after })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [after], allAgents: [after] })
|
||||
|
||||
await expect(
|
||||
useAgentStore
|
||||
.getState()
|
||||
.setAgentOverride('Explore', { cwd: '/workspace/current', model: 'sonnet' }),
|
||||
).resolves.toBe(after)
|
||||
|
||||
expect(apiSetOverrideMock).toHaveBeenCalledWith('Explore', {
|
||||
cwd: '/workspace/current',
|
||||
model: 'sonnet',
|
||||
})
|
||||
// Built-ins carry no scope or target, so the lookup has to match on
|
||||
// source alone — findEditableAgent would never return one.
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
selectedAgent: after,
|
||||
allAgents: [after],
|
||||
isMutating: false,
|
||||
mutationError: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the reset result from the server instead of reconstructing a default', async () => {
|
||||
const overridden = makeBuiltInAgent({
|
||||
model: 'sonnet',
|
||||
override: { model: 'sonnet', source: 'userSettings' },
|
||||
})
|
||||
// The shipped default differs per agent and per build, so the store must
|
||||
// never guess it — whatever the server returns is the answer.
|
||||
const shipped = makeBuiltInAgent({ model: 'haiku', modelDisplay: 'haiku' })
|
||||
useAgentStore.setState({ selectedAgent: overridden, allAgents: [overridden] })
|
||||
apiClearOverrideMock.mockResolvedValue({ agent: shipped })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [shipped], allAgents: [shipped] })
|
||||
|
||||
await expect(
|
||||
useAgentStore.getState().clearAgentOverride('Explore', '/workspace/current'),
|
||||
).resolves.toBe(shipped)
|
||||
|
||||
expect(apiClearOverrideMock).toHaveBeenCalledWith('Explore', '/workspace/current')
|
||||
expect(useAgentStore.getState().selectedAgent?.model).toBe('haiku')
|
||||
expect(useAgentStore.getState().selectedAgent?.override).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reloads the CLI session after an override so a running session picks it up', async () => {
|
||||
const after = makeBuiltInAgent({ model: 'sonnet' })
|
||||
apiSetOverrideMock.mockResolvedValue({ agent: after })
|
||||
apiListMock.mockResolvedValue({ activeAgents: [after], allAgents: [after] })
|
||||
apiReloadMock.mockResolvedValue({
|
||||
ok: true,
|
||||
session: {
|
||||
applied: false,
|
||||
reason: 'failed',
|
||||
commands: 0,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
mcpServers: 0,
|
||||
errors: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await useAgentStore
|
||||
.getState()
|
||||
.setAgentOverride('Explore', { cwd: '/workspace/current', model: 'sonnet' }, 'session-1')
|
||||
|
||||
expect(apiReloadMock).toHaveBeenCalledWith('session-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(useAgentStore.getState().mutationWarning).toBe(
|
||||
'Failed to reload agent definitions in the active CLI session',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a successful override when the refreshed list fails to arrive', async () => {
|
||||
const after = makeBuiltInAgent({ model: 'sonnet' })
|
||||
apiSetOverrideMock.mockResolvedValue({ agent: after })
|
||||
apiListMock.mockRejectedValue(new Error('Network down'))
|
||||
|
||||
await expect(
|
||||
useAgentStore.getState().setAgentOverride('Explore', { model: 'sonnet' }),
|
||||
).resolves.toBe(after)
|
||||
|
||||
// The write already landed on disk; dropping it from the list would show
|
||||
// the user a stale model that no longer matches settings.json.
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
allAgents: [after],
|
||||
selectedAgent: after,
|
||||
isMutating: false,
|
||||
})
|
||||
expect(useAgentStore.getState().mutationWarning).toContain('Network down')
|
||||
})
|
||||
|
||||
it('does not let an override refresh overwrite a later project switch', async () => {
|
||||
const mutationRefresh = deferred<{ activeAgents: AgentDefinition[]; allAgents: AgentDefinition[] }>()
|
||||
const overridden = makeBuiltInAgent({ model: 'sonnet' })
|
||||
const currentAgent = makeAgent({ agentType: 'current-project', source: 'projectSettings' })
|
||||
apiSetOverrideMock.mockResolvedValue({ agent: overridden })
|
||||
apiListMock
|
||||
.mockReturnValueOnce(mutationRefresh.promise)
|
||||
.mockResolvedValueOnce({ activeAgents: [currentAgent], allAgents: [currentAgent] })
|
||||
|
||||
const mutation = useAgentStore.getState().setAgentOverride('Explore', { model: 'sonnet' })
|
||||
await vi.waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
|
||||
await useAgentStore.getState().fetchAgents('/workspace/current-project')
|
||||
mutationRefresh.resolve({ activeAgents: [overridden], allAgents: [overridden] })
|
||||
await mutation
|
||||
|
||||
// The override's own refresh resolved last but describes an older project.
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
allAgents: [currentAgent],
|
||||
isMutating: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes an override failure without losing the selection', async () => {
|
||||
const selectedAgent = makeBuiltInAgent()
|
||||
useAgentStore.setState({ selectedAgent })
|
||||
apiSetOverrideMock.mockRejectedValue(new Error('Agent customization is restricted'))
|
||||
|
||||
await expect(
|
||||
useAgentStore.getState().setAgentOverride('Explore', { model: 'sonnet' }),
|
||||
).rejects.toThrow('Agent customization is restricted')
|
||||
|
||||
expect(useAgentStore.getState()).toMatchObject({
|
||||
selectedAgent,
|
||||
isMutating: false,
|
||||
mutationError: 'Agent customization is restricted',
|
||||
})
|
||||
})
|
||||
|
||||
it('resets the return destination when clearing a selection', () => {
|
||||
const agent = makeAgent()
|
||||
|
||||
|
||||
+209
-150
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand'
|
||||
import { create, type StoreApi } from 'zustand'
|
||||
import {
|
||||
agentsApi,
|
||||
type AgentDefinition,
|
||||
type AgentMutationInput,
|
||||
type AgentOverrideInput,
|
||||
type AgentScope,
|
||||
type AgentSource,
|
||||
} from '../api/agents'
|
||||
@@ -39,6 +40,16 @@ type AgentStore = {
|
||||
target?: string,
|
||||
sessionId?: string,
|
||||
) => Promise<void>
|
||||
setAgentOverride: (
|
||||
name: string,
|
||||
input: AgentOverrideInput,
|
||||
sessionId?: string,
|
||||
) => Promise<AgentDefinition>
|
||||
clearAgentOverride: (
|
||||
name: string,
|
||||
cwd?: string,
|
||||
sessionId?: string,
|
||||
) => Promise<AgentDefinition>
|
||||
selectAgent: (
|
||||
agent: AgentDefinition | null,
|
||||
returnTab?: AgentDetailReturnTab,
|
||||
@@ -48,6 +59,14 @@ type AgentStore = {
|
||||
let latestFetchRequestId = 0
|
||||
let latestMutationRequestId = 0
|
||||
|
||||
/**
|
||||
* Spelled out rather than `typeof useAgentStore.setState`: helpers below are
|
||||
* reached from the store's own action bodies, so referring back to the store
|
||||
* makes its type self-referential and TypeScript falls back to `any` for the
|
||||
* whole store — and for every component that reads it.
|
||||
*/
|
||||
type AgentStoreSetter = StoreApi<AgentStore>['setState']
|
||||
|
||||
export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
@@ -116,161 +135,85 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
},
|
||||
|
||||
createAgent: async (input, sessionId) => {
|
||||
const requestId = ++latestMutationRequestId
|
||||
const displayRequestId = ++latestFetchRequestId
|
||||
set({
|
||||
isMutating: true,
|
||||
mutationError: null,
|
||||
mutationWarning: null,
|
||||
isLoading: false,
|
||||
let createdTarget: string | undefined
|
||||
return runAgentMutation({
|
||||
mutate: async () => {
|
||||
const { agent } = await agentsApi.create(input)
|
||||
createdTarget = agent.target
|
||||
return agent
|
||||
},
|
||||
locate: (agents) =>
|
||||
findEditableAgent(
|
||||
agents,
|
||||
input.name,
|
||||
input.scope,
|
||||
createdTarget ?? input.target,
|
||||
),
|
||||
cwd: input.cwd,
|
||||
sessionId,
|
||||
mutationErrorFallback: 'Failed to create agent',
|
||||
refreshErrorFallback: 'Failed to refresh agents after creating the agent',
|
||||
missingAfterRefreshMessage:
|
||||
'Created agent was not returned by the refreshed list',
|
||||
set,
|
||||
})
|
||||
let createdAgent: AgentDefinition
|
||||
try {
|
||||
const mutationResponse = await agentsApi.create(input)
|
||||
createdAgent = mutationResponse.agent
|
||||
} catch (error) {
|
||||
if (requestId === latestMutationRequestId) {
|
||||
const message = getErrorMessage(error, 'Failed to create agent')
|
||||
set({
|
||||
isMutating: false,
|
||||
...(displayRequestId === latestFetchRequestId ? { mutationError: message } : {}),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
startSessionReloadWarning(sessionId, requestId, displayRequestId, set)
|
||||
try {
|
||||
const response = await agentsApi.list(input.cwd)
|
||||
const refreshedAgent = findEditableAgent(
|
||||
response.allAgents,
|
||||
input.name,
|
||||
input.scope,
|
||||
createdAgent.target ?? input.target,
|
||||
)
|
||||
if (!refreshedAgent) {
|
||||
throw new Error('Created agent was not returned by the refreshed list')
|
||||
}
|
||||
if (requestId !== latestMutationRequestId) return refreshedAgent
|
||||
if (displayRequestId !== latestFetchRequestId) {
|
||||
set({ isMutating: false })
|
||||
return refreshedAgent
|
||||
}
|
||||
const contextCwd = normalizeAgentCwd(input.cwd)
|
||||
set({
|
||||
...response,
|
||||
selectedAgent: refreshedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
requestedCwd: contextCwd,
|
||||
resolvedCwd: contextCwd,
|
||||
isContextStale: false,
|
||||
})
|
||||
return refreshedAgent
|
||||
} catch (refreshError) {
|
||||
if (requestId === latestMutationRequestId && displayRequestId !== latestFetchRequestId) {
|
||||
set({ isMutating: false })
|
||||
} else if (requestId === latestMutationRequestId) {
|
||||
const contextCwd = normalizeAgentCwd(input.cwd)
|
||||
set((state) => ({
|
||||
...upsertMutationAgent(state, createdAgent),
|
||||
selectedAgent: createdAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
requestedCwd: contextCwd,
|
||||
resolvedCwd: contextCwd,
|
||||
isContextStale: false,
|
||||
mutationWarning: combineWarnings(
|
||||
getErrorMessage(
|
||||
refreshError,
|
||||
'Failed to refresh agents after creating the agent',
|
||||
),
|
||||
state.mutationWarning,
|
||||
),
|
||||
}))
|
||||
}
|
||||
return createdAgent
|
||||
}
|
||||
},
|
||||
|
||||
updateAgent: async (name, input, sessionId) => {
|
||||
const requestId = ++latestMutationRequestId
|
||||
const displayRequestId = ++latestFetchRequestId
|
||||
set({
|
||||
isMutating: true,
|
||||
mutationError: null,
|
||||
mutationWarning: null,
|
||||
isLoading: false,
|
||||
let updatedTarget: string | undefined
|
||||
return runAgentMutation({
|
||||
mutate: async () => {
|
||||
const { agent } = await agentsApi.update(name, input)
|
||||
updatedTarget = agent.target
|
||||
return agent
|
||||
},
|
||||
locate: (agents) =>
|
||||
findEditableAgent(
|
||||
agents,
|
||||
input.name,
|
||||
input.scope,
|
||||
updatedTarget ?? input.target,
|
||||
),
|
||||
cwd: input.cwd,
|
||||
sessionId,
|
||||
mutationErrorFallback: 'Failed to update agent',
|
||||
refreshErrorFallback: 'Failed to refresh agents after updating the agent',
|
||||
missingAfterRefreshMessage:
|
||||
'Updated agent was not returned by the refreshed list',
|
||||
set,
|
||||
})
|
||||
let updatedAgent: AgentDefinition
|
||||
try {
|
||||
const mutationResponse = await agentsApi.update(name, input)
|
||||
updatedAgent = mutationResponse.agent
|
||||
} catch (error) {
|
||||
if (requestId === latestMutationRequestId) {
|
||||
const message = getErrorMessage(error, 'Failed to update agent')
|
||||
set({
|
||||
isMutating: false,
|
||||
...(displayRequestId === latestFetchRequestId ? { mutationError: message } : {}),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
startSessionReloadWarning(sessionId, requestId, displayRequestId, set)
|
||||
try {
|
||||
const response = await agentsApi.list(input.cwd)
|
||||
const refreshedAgent = findEditableAgent(
|
||||
response.allAgents,
|
||||
input.name,
|
||||
input.scope,
|
||||
updatedAgent.target ?? input.target,
|
||||
)
|
||||
if (!refreshedAgent) {
|
||||
throw new Error('Updated agent was not returned by the refreshed list')
|
||||
}
|
||||
if (requestId !== latestMutationRequestId) return refreshedAgent
|
||||
if (displayRequestId !== latestFetchRequestId) {
|
||||
set({ isMutating: false })
|
||||
return refreshedAgent
|
||||
}
|
||||
const contextCwd = normalizeAgentCwd(input.cwd)
|
||||
set({
|
||||
...response,
|
||||
selectedAgent: refreshedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
requestedCwd: contextCwd,
|
||||
resolvedCwd: contextCwd,
|
||||
isContextStale: false,
|
||||
})
|
||||
return refreshedAgent
|
||||
} catch (refreshError) {
|
||||
if (requestId === latestMutationRequestId && displayRequestId !== latestFetchRequestId) {
|
||||
set({ isMutating: false })
|
||||
} else if (requestId === latestMutationRequestId) {
|
||||
const contextCwd = normalizeAgentCwd(input.cwd)
|
||||
set((state) => ({
|
||||
...upsertMutationAgent(state, updatedAgent),
|
||||
selectedAgent: updatedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
requestedCwd: contextCwd,
|
||||
resolvedCwd: contextCwd,
|
||||
isContextStale: false,
|
||||
mutationWarning: combineWarnings(
|
||||
getErrorMessage(
|
||||
refreshError,
|
||||
'Failed to refresh agents after updating the agent',
|
||||
),
|
||||
state.mutationWarning,
|
||||
),
|
||||
}))
|
||||
}
|
||||
return updatedAgent
|
||||
}
|
||||
},
|
||||
|
||||
setAgentOverride: async (name, input, sessionId) =>
|
||||
runAgentMutation({
|
||||
mutate: async () => (await agentsApi.setOverride(name, input)).agent,
|
||||
locate: (agents) => findBuiltInAgent(agents, name),
|
||||
cwd: input.cwd,
|
||||
sessionId,
|
||||
mutationErrorFallback: 'Failed to save the built-in agent override',
|
||||
refreshErrorFallback:
|
||||
'Failed to refresh agents after saving the built-in agent override',
|
||||
missingAfterRefreshMessage:
|
||||
'Overridden agent was not returned by the refreshed list',
|
||||
set,
|
||||
}),
|
||||
|
||||
clearAgentOverride: async (name, cwd, sessionId) =>
|
||||
runAgentMutation({
|
||||
// The server decides what "built-in default" is — this build's default
|
||||
// differs per agent, so the store must never reconstruct it locally.
|
||||
mutate: async () => (await agentsApi.clearOverride(name, cwd)).agent,
|
||||
locate: (agents) => findBuiltInAgent(agents, name),
|
||||
cwd,
|
||||
sessionId,
|
||||
mutationErrorFallback: 'Failed to reset the built-in agent override',
|
||||
refreshErrorFallback:
|
||||
'Failed to refresh agents after resetting the built-in agent override',
|
||||
missingAfterRefreshMessage:
|
||||
'Reset agent was not returned by the refreshed list',
|
||||
set,
|
||||
}),
|
||||
|
||||
deleteAgent: async (name, scope, cwd, target, sessionId) => {
|
||||
const requestId = ++latestMutationRequestId
|
||||
const displayRequestId = ++latestFetchRequestId
|
||||
@@ -358,6 +301,122 @@ function normalizeAgentCwd(cwd?: string): string | null {
|
||||
return cwd ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared body for every mutation that ends with "reload the list and select the
|
||||
* agent I just changed": create, update, and the built-in override routes.
|
||||
*
|
||||
* The out-of-order guards are the reason this is shared rather than copied. A
|
||||
* mutation and a project switch race constantly here, and each of the two
|
||||
* counters answers a different question — `latestMutationRequestId` whether
|
||||
* this mutation is still the newest one, `latestFetchRequestId` whether the
|
||||
* list on screen is still the one this mutation was started against. A third
|
||||
* hand-written copy is how one of them goes missing.
|
||||
*
|
||||
* Delete is deliberately not routed through here: it clears the selection and
|
||||
* removes rather than upserts.
|
||||
*/
|
||||
async function runAgentMutation({
|
||||
mutate,
|
||||
locate,
|
||||
cwd,
|
||||
sessionId,
|
||||
mutationErrorFallback,
|
||||
refreshErrorFallback,
|
||||
missingAfterRefreshMessage,
|
||||
set,
|
||||
}: {
|
||||
mutate: () => Promise<AgentDefinition>
|
||||
locate: (agents: AgentDefinition[]) => AgentDefinition | undefined
|
||||
cwd: string | undefined
|
||||
sessionId: string | undefined
|
||||
mutationErrorFallback: string
|
||||
refreshErrorFallback: string
|
||||
missingAfterRefreshMessage: string
|
||||
set: AgentStoreSetter
|
||||
}): Promise<AgentDefinition> {
|
||||
const requestId = ++latestMutationRequestId
|
||||
const displayRequestId = ++latestFetchRequestId
|
||||
set({
|
||||
isMutating: true,
|
||||
mutationError: null,
|
||||
mutationWarning: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
let mutatedAgent: AgentDefinition
|
||||
try {
|
||||
mutatedAgent = await mutate()
|
||||
} catch (error) {
|
||||
if (requestId === latestMutationRequestId) {
|
||||
const message = getErrorMessage(error, mutationErrorFallback)
|
||||
set({
|
||||
isMutating: false,
|
||||
...(displayRequestId === latestFetchRequestId ? { mutationError: message } : {}),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
startSessionReloadWarning(sessionId, requestId, displayRequestId, set)
|
||||
try {
|
||||
// Refetch rather than trusting the mutation response: overrides and
|
||||
// overriddenBy are computed across every source, so only a full list is
|
||||
// consistent.
|
||||
const response = await agentsApi.list(cwd)
|
||||
const refreshedAgent = locate(response.allAgents)
|
||||
if (!refreshedAgent) {
|
||||
throw new Error(missingAfterRefreshMessage)
|
||||
}
|
||||
if (requestId !== latestMutationRequestId) return refreshedAgent
|
||||
if (displayRequestId !== latestFetchRequestId) {
|
||||
set({ isMutating: false })
|
||||
return refreshedAgent
|
||||
}
|
||||
const contextCwd = normalizeAgentCwd(cwd)
|
||||
set({
|
||||
...response,
|
||||
selectedAgent: refreshedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
requestedCwd: contextCwd,
|
||||
resolvedCwd: contextCwd,
|
||||
isContextStale: false,
|
||||
})
|
||||
return refreshedAgent
|
||||
} catch (refreshError) {
|
||||
if (requestId === latestMutationRequestId && displayRequestId !== latestFetchRequestId) {
|
||||
set({ isMutating: false })
|
||||
} else if (requestId === latestMutationRequestId) {
|
||||
const contextCwd = normalizeAgentCwd(cwd)
|
||||
set((state) => ({
|
||||
...upsertMutationAgent(state, mutatedAgent),
|
||||
selectedAgent: mutatedAgent,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
isMutating: false,
|
||||
requestedCwd: contextCwd,
|
||||
resolvedCwd: contextCwd,
|
||||
isContextStale: false,
|
||||
mutationWarning: combineWarnings(
|
||||
getErrorMessage(refreshError, refreshErrorFallback),
|
||||
state.mutationWarning,
|
||||
),
|
||||
}))
|
||||
}
|
||||
return mutatedAgent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in agents carry no scope or target — they are identified by name and
|
||||
* source alone, so findEditableAgent (which filters on user/project sources)
|
||||
* would never match one.
|
||||
*/
|
||||
function findBuiltInAgent(agents: AgentDefinition[], name: string) {
|
||||
return agents.find(
|
||||
(agent) => agent.agentType === name && agent.source === 'built-in',
|
||||
)
|
||||
}
|
||||
|
||||
function findEditableAgent(
|
||||
agents: AgentDefinition[],
|
||||
name: string,
|
||||
@@ -434,7 +493,7 @@ function startSessionReloadWarning(
|
||||
sessionId: string | undefined,
|
||||
requestId: number,
|
||||
displayRequestId: number,
|
||||
setState: typeof useAgentStore.setState,
|
||||
setState: AgentStoreSetter,
|
||||
) {
|
||||
void getSessionReloadWarning(sessionId).then((reloadWarning) => {
|
||||
if (
|
||||
|
||||
@@ -2173,6 +2173,14 @@ html[data-touch-h5] [data-message-actions] {
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
/* Agent list rows reveal their edit / delete / override buttons on hover, which
|
||||
never fires on a touchscreen — the controls would be unreachable there. */
|
||||
.app-shell--mobile [data-agent-row-actions],
|
||||
html[data-touch-h5] [data-agent-row-actions] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Kill the gray flash WKWebView paints over any tapped interactive element;
|
||||
components carry their own pressed/hover styling. */
|
||||
html[data-touch-h5],
|
||||
|
||||
@@ -51,6 +51,27 @@ order: 3
|
||||
|
||||
点任意一条进详情页,能看到它的模型、思考强度、工具范围和完整系统提示词。内置和插件来源是只读的,详情页右上角会有一个「只读」标记。
|
||||
|
||||
鼠标移到列表里的某一行上,右侧会出现操作按钮:用户和项目 Agent 是「编辑」和「删除」,内置 Agent 是「调整模型」。详情页右上角也有同样的入口。
|
||||
|
||||
## 调整内置 Agent 的模型
|
||||
|
||||
内置 Agent 各自钉了默认模型——`Explore` 和 `claude-code-guide` 走 Haiku,`statusline-setup` 走 Sonnet——图的是快和省。如果你更在意它们的结果质量,可以单独换掉。
|
||||
|
||||
在列表里点内置 Agent 那一行的「调整模型」,或者进详情页点右上角的同名按钮。能改的只有两项:
|
||||
|
||||
- **模型** — 「内置默认」「继承主会话」「Haiku / Sonnet / Opus / Fable」,也可以填自定义模型 ID。
|
||||
- **思考强度** — 「内置默认」或低 / 中 / 高 / 极高 / 最大。
|
||||
|
||||
系统提示词、工具范围和颜色不能改,仍由 Claude Code 固定。
|
||||
|
||||
:::tip
|
||||
「内置默认」和「继承主会话」是两回事。以 `Explore` 为例,前者是它出厂就钉着的 Haiku,后者是跟着你主对话当前用的模型走。想恢复出厂设置就选「内置默认」,或者直接点「恢复内置默认」。
|
||||
:::
|
||||
|
||||
覆盖写进 `~/.claude/settings.json` 的 `builtInAgentOverrides`,对所有项目生效。恢复默认时这条记录会被整个删掉,不会在配置文件里留下空壳。
|
||||
|
||||
如果你自己建了一个同名的用户 Agent(比如手写一个 `name: Explore` 的 md 文件),它会完全盖住内置的那个,此时改内置的模型不会有任何效果——弹窗里会提示这一点。
|
||||
|
||||
## 捏一个自己的
|
||||
|
||||

|
||||
|
||||
@@ -51,6 +51,27 @@ When two agents share a name, the higher source wins and the shadowed one is tag
|
||||
|
||||
Click any row for its detail page: model, effort, tool scope, and the full system prompt. Built-in and plugin agents are read-only and show a lock pill instead of edit controls.
|
||||
|
||||
Hover a row in the list and its actions appear on the right: **Edit** and **Delete** for user and project agents, **Adjust model** for built-in ones. The same controls sit in the top-right of the detail page.
|
||||
|
||||
## Adjusting a Built-in Agent's Model
|
||||
|
||||
Built-in agents each pin a default model — `Explore` and `claude-code-guide` run on Haiku, `statusline-setup` on Sonnet — chosen for speed and cost. If you care more about their output quality, you can swap that out.
|
||||
|
||||
Click **Adjust model** on the built-in agent's row, or the same button in the top-right of its detail page. Only two things are editable:
|
||||
|
||||
- **Model** — Built-in default, Inherit from parent, Haiku / Sonnet / Opus / Fable, or a custom model ID.
|
||||
- **Reasoning effort** — Built-in default, or low / medium / high / xhigh / max.
|
||||
|
||||
The system prompt, tool scope, and color stay fixed by Claude Code.
|
||||
|
||||
:::tip
|
||||
"Built-in default" and "Inherit from parent" are not the same thing. For `Explore`, the first is the Haiku it ships pinned to; the second follows whatever model your main conversation is using. To go back to how it shipped, pick "Built-in default" or click **Reset to built-in default**.
|
||||
:::
|
||||
|
||||
The override is written to `builtInAgentOverrides` in `~/.claude/settings.json` and applies to every project. Resetting removes the entry entirely rather than leaving an empty shell behind in your config.
|
||||
|
||||
If you create a user agent with the same name (a hand-written file with `name: Explore`, say), it shadows the built-in one completely — changing the built-in's model then has no effect, and the dialog says so.
|
||||
|
||||
## Writing your own
|
||||
|
||||

|
||||
|
||||
@@ -350,7 +350,7 @@ In the desktop app, open **Settings → Agents**. The desktop app and CLI use th
|
||||
| **User** | `~/.claude/agents/*.md` | Available in every project |
|
||||
| **Project** | `<project-directory>/.claude/agents/*.md` | Available only in the current project; overrides a user Agent with the same name |
|
||||
|
||||
User and project Agents can be created, edited, and deleted from the desktop app, and saving writes directly to the corresponding Markdown file. Other sources, including built-in Agents, plugins, managed policy, and CLI arguments, also appear in the list but are read-only because the desktop app does not own their source files.
|
||||
User and project Agents can be created, edited, and deleted from the desktop app, and saving writes directly to the corresponding Markdown file. Other sources, including built-in Agents, plugins, managed policy, and CLI arguments, also appear in the list but are read-only because the desktop app does not own their source files. Built-in Agents are one exception: their model and reasoning effort can be overridden individually, but the override lives in settings.json rather than in a source file and `source` stays `built-in`. See "Overriding a Built-in Agent's Model and Effort" below.
|
||||
|
||||
When the desktop app is connected to the active session, creating, editing, or deleting an Agent hot-reloads that session in place, so the next spawn uses the new definition immediately. If no runtime is available or the reload fails, the file is still saved; the desktop app shows a non-blocking warning, and the saved definition is loaded on the next launch.
|
||||
|
||||
@@ -410,14 +410,39 @@ Model resolution uses this precedence, from highest to lowest:
|
||||
1. A concrete model in `CLAUDE_CODE_SUBAGENT_MODEL` (`inherit` does not pin the model)
|
||||
2. The model supplied to this `Agent({ ..., model: "..." })` call
|
||||
3. `model` in the Agent Markdown frontmatter
|
||||
4. The primary conversation model
|
||||
4. `model` from `builtInAgentOverrides` in settings.json (built-in Agents only)
|
||||
5. The primary conversation model
|
||||
|
||||
Reasoning effort resolution uses this precedence, from highest to lowest:
|
||||
|
||||
1. `CLAUDE_CODE_EFFORT_LEVEL`
|
||||
2. `effort` in the Agent Markdown frontmatter
|
||||
3. The current session effort
|
||||
4. The model default
|
||||
3. `effort` from `builtInAgentOverrides` in settings.json (built-in Agents only)
|
||||
4. The current session effort
|
||||
5. The model default
|
||||
|
||||
### Overriding a Built-in Agent's Model and Effort
|
||||
|
||||
Built-in Agents have no Markdown file, so their `model` and `effort` are adjusted through `builtInAgentOverrides` in settings.json. Keys are the agentType used to spawn them, and are case-sensitive:
|
||||
|
||||
```json
|
||||
{
|
||||
"builtInAgentOverrides": {
|
||||
"Explore": { "model": "sonnet", "effort": "low" },
|
||||
"general-purpose": { "effort": "high" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A few rules that are easy to get wrong:
|
||||
|
||||
- **Only these two fields are writable.** The system prompt, tool scope, and color still come from the built-in definition, and the override does not change `source` — so a built-in Agent keeps its built-in tool privileges.
|
||||
- **Clearing an override means deleting the field**, not writing `inherit`. Built-in defaults differ per agent (`Explore` defaults to `haiku`, `Plan` to `inherit`, and `general-purpose` omits `model`), so `model: "inherit"` is a normal value meaning "follow the primary session" — distinct from "restore the default".
|
||||
- **Unknown agentTypes are ignored but never pruned.** The built-in set varies with feature flags and entrypoint, so automatic cleanup would destroy a valid configuration whenever a flag flipped.
|
||||
- When the managed `strictPluginOnlyCustomization` policy includes `agents`, user- and project-level overrides are ignored **at load time**, and only managed sources apply.
|
||||
- As with Agent Markdown files, editing settings.json does not affect an already-running session. The desktop app triggers a session reload when it saves; editing the file by hand requires restarting the session or `/reload-plugins`.
|
||||
|
||||
For the desktop entry point, see [Subagents and Task Splitting](../desktop/agents.md).
|
||||
|
||||
The `Agent` tool has no per-call `effort` parameter, so set it in the Agent definition or at the session level. Availability of `low`, `medium`, `high`, `xhigh`, and `max` depends on the resolved model and provider capabilities. Claude models fall back to a lower supported level, other providers normalize through their model catalogs, and models without effort support do not apply the field.
|
||||
|
||||
|
||||
+29
-4
@@ -350,7 +350,7 @@ Agent Teams 支持两种执行后端:
|
||||
| **用户** | `~/.claude/agents/*.md` | 在所有项目中可用 |
|
||||
| **项目** | `<项目目录>/.claude/agents/*.md` | 只在当前项目中可用;同名时覆盖用户 Agent |
|
||||
|
||||
用户和项目 Agent 可以在桌面端创建、编辑和删除,保存结果会直接写回对应的 Markdown 文件。内置、插件、托管策略和 CLI 参数等其他来源也会显示在列表中,但只能查看,不能从桌面端改写其来源文件。
|
||||
用户和项目 Agent 可以在桌面端创建、编辑和删除,保存结果会直接写回对应的 Markdown 文件。内置、插件、托管策略和 CLI 参数等其他来源也会显示在列表中,但只能查看,不能从桌面端改写其来源文件。内置 Agent 是一个例外:它的模型和推理强度可以单独覆盖,但覆盖写在 settings.json 而不是来源文件里,`source` 仍然是 `built-in`,详见下文「覆盖内置 Agent 的模型与推理强度」。
|
||||
|
||||
当桌面端连接着当前运行会话时,创建、编辑或删除 Agent 会原地热重载该会话,下一次 spawn 立即使用新定义。如果没有可用的运行时,或热重载失败,文件保存仍然成功;桌面端会显示不阻塞操作的警告,并在下次启动时自动读取已保存的定义。
|
||||
|
||||
@@ -410,14 +410,39 @@ maxTurns: 10
|
||||
1. `CLAUDE_CODE_SUBAGENT_MODEL` 的具体模型值(设为 `inherit` 时不锁定模型)
|
||||
2. 本次 `Agent({ ..., model: "..." })` 调用指定的模型
|
||||
3. Agent Markdown frontmatter 中的 `model`
|
||||
4. 主会话模型
|
||||
4. settings.json 中 `builtInAgentOverrides` 指定的 `model`(仅内置 Agent)
|
||||
5. 主会话模型
|
||||
|
||||
推理强度的解析优先级从高到低为:
|
||||
|
||||
1. `CLAUDE_CODE_EFFORT_LEVEL`
|
||||
2. Agent Markdown frontmatter 中的 `effort`
|
||||
3. 当前会话的 effort
|
||||
4. 模型默认值
|
||||
3. settings.json 中 `builtInAgentOverrides` 指定的 `effort`(仅内置 Agent)
|
||||
4. 当前会话的 effort
|
||||
5. 模型默认值
|
||||
|
||||
### 覆盖内置 Agent 的模型与推理强度
|
||||
|
||||
内置 Agent 没有 Markdown 文件,因此它的 `model` / `effort` 通过 settings.json 的 `builtInAgentOverrides` 调整,key 是 spawn 时用的 agentType(大小写敏感):
|
||||
|
||||
```json
|
||||
{
|
||||
"builtInAgentOverrides": {
|
||||
"Explore": { "model": "sonnet", "effort": "low" },
|
||||
"general-purpose": { "effort": "high" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
几条容易踩的规则:
|
||||
|
||||
- **只有这两个字段可改。** 系统提示词、工具范围和颜色仍由内置定义决定,覆盖不会改变 `source`,因此内置 Agent 的工具特权保持不变。
|
||||
- **清除覆盖 = 删掉字段**,不是写 `inherit`。各内置 Agent 的默认值互不相同(`Explore` 默认 `haiku`、`Plan` 默认 `inherit`、`general-purpose` 不写 `model`),所以 `model: "inherit"` 是一个正常取值,含义是跟随主会话,与"恢复默认"不同。
|
||||
- **未知的 agentType 会被忽略但不会被清理。** 内置 Agent 集合随 feature flag 和 entrypoint 变化,自动清理会在开关翻转时销毁有效配置。
|
||||
- 组织策略 `strictPluginOnlyCustomization` 包含 `agents` 时,用户级和项目级的覆盖在**解析阶段**就会被忽略,只有 managed 来源生效。
|
||||
- 与 Agent Markdown 文件一样,改完 settings.json 不会自动作用于已经在跑的会话;桌面端保存时会触发一次会话重载,手工改文件则需要重启会话或 `/reload-plugins`。
|
||||
|
||||
对应的桌面端入口见[子 Agent 与任务拆分](../desktop/agents.md)。
|
||||
|
||||
`Agent` 工具没有单次调用的 `effort` 参数,因此应在 Agent 定义或会话层设置。`low`、`medium`、`high`、`xhigh`、`max` 是否可用取决于解析后的真实模型及提供商能力;Claude 模型会向下回退到可用档位,其他提供商按各自的模型目录规范化,不支持 effort 的模型不会应用该字段。
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from 'node:path'
|
||||
import type { AppState } from '../../state/AppStateStore.js'
|
||||
import { handleAgentsApi } from '../api/agents.js'
|
||||
import { clearAgentDefinitionsCache } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
import { findGitRoot } from '../../utils/git.js'
|
||||
import { refreshActivePlugins } from '../../utils/plugins/refresh.js'
|
||||
import { AgentService } from '../services/agentService.js'
|
||||
@@ -36,6 +37,9 @@ beforeEach(async () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
process.env.CLAUDE_CODE_USE_NATIVE_FILE_SEARCH = '1'
|
||||
clearAgentDefinitionsCache()
|
||||
// Built-in overrides are read from settings, which caches per source and per
|
||||
// file path — without this each test would see the previous temp config dir.
|
||||
resetSettingsCache()
|
||||
__resetWebSocketHandlerStateForTests()
|
||||
originalHasSession = conversationService.hasSession.bind(conversationService)
|
||||
originalRequestControl = conversationService.requestControl.bind(conversationService)
|
||||
@@ -46,6 +50,7 @@ afterEach(async () => {
|
||||
conversationService.requestControl = originalRequestControl
|
||||
__resetWebSocketHandlerStateForTests()
|
||||
clearAgentDefinitionsCache()
|
||||
resetSettingsCache()
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
if (originalNativeSearch === undefined) {
|
||||
@@ -1523,6 +1528,260 @@ describe('Agents API Markdown CRUD', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agents API built-in overrides', () => {
|
||||
async function readUserSettings(): Promise<Record<string, any>> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await fs.readFile(path.join(configDir, 'settings.json'), 'utf-8'),
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeUserSettings(settings: unknown): Promise<void> {
|
||||
await fs.writeFile(
|
||||
path.join(configDir, 'settings.json'),
|
||||
JSON.stringify(settings),
|
||||
)
|
||||
resetSettingsCache()
|
||||
clearAgentDefinitionsCache()
|
||||
}
|
||||
|
||||
async function getAgent(name: string) {
|
||||
const list = await api(
|
||||
'GET',
|
||||
`/api/agents?cwd=${encodeURIComponent(projectCwd)}`,
|
||||
)
|
||||
return list.data.allAgents.find((agent: any) => agent.agentType === name)
|
||||
}
|
||||
|
||||
it('overrides a built-in model while the plain PUT route still refuses it', async () => {
|
||||
// Both halves target the same agent, in one test, on purpose. Split apart,
|
||||
// loosening assertMutableTarget would leave each of them green.
|
||||
// `general-purpose` specifically: it is the only shape of built-in name the
|
||||
// generic route can even address, since AGENT_SLUG_PATTERN rejects
|
||||
// `Explore` with a 400 long before the read-only check runs.
|
||||
const before = await getAgent('general-purpose')
|
||||
expect(before.overridable).toBe(true)
|
||||
expect(before.editable).toBe(false)
|
||||
const shippedModel = before.defaults.model
|
||||
|
||||
const overridden = await api(
|
||||
'PUT',
|
||||
'/api/agents/general-purpose/override',
|
||||
{ cwd: projectCwd, model: 'sonnet', effort: 'low' },
|
||||
)
|
||||
expect(overridden.status).toBe(200)
|
||||
expect(overridden.data.agent.model).toBe('sonnet')
|
||||
expect(overridden.data.agent.modelDisplay).toBe('sonnet')
|
||||
expect(overridden.data.agent.effort).toBe('low')
|
||||
expect(overridden.data.agent.source).toBe('built-in')
|
||||
// Narrow capability, not general write access.
|
||||
expect(overridden.data.agent.editable).toBe(false)
|
||||
expect(overridden.data.agent.overridable).toBe(true)
|
||||
// The shipped default must survive so the UI can offer "reset".
|
||||
expect(overridden.data.agent.defaults.model).toBe(shippedModel)
|
||||
expect(overridden.data.agent.override).toEqual({
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
source: 'userSettings',
|
||||
})
|
||||
|
||||
const readOnlyUpdate = await api('PUT', '/api/agents/general-purpose', {
|
||||
scope: 'user',
|
||||
cwd: projectCwd,
|
||||
description: 'Attempt to rewrite a built-in through the generic route',
|
||||
})
|
||||
expect(readOnlyUpdate.status).toBe(403)
|
||||
expect(readOnlyUpdate.data.error).toBe('READ_ONLY_AGENT')
|
||||
|
||||
const readOnlyDelete = await api(
|
||||
'DELETE',
|
||||
`/api/agents/general-purpose?scope=user&cwd=${encodeURIComponent(projectCwd)}`,
|
||||
)
|
||||
expect(readOnlyDelete.status).toBe(403)
|
||||
expect(readOnlyDelete.data.error).toBe('READ_ONLY_AGENT')
|
||||
})
|
||||
|
||||
it('writes only the requested field and leaves the rest of settings.json alone', async () => {
|
||||
await writeUserSettings({
|
||||
model: 'opus',
|
||||
builtInAgentOverrides: { Plan: { model: 'haiku' } },
|
||||
})
|
||||
|
||||
await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
})
|
||||
|
||||
const settings = await readUserSettings()
|
||||
expect(settings.model).toBe('opus')
|
||||
expect(settings.builtInAgentOverrides.Plan).toEqual({ model: 'haiku' })
|
||||
expect(settings.builtInAgentOverrides.Explore).toEqual({ model: 'sonnet' })
|
||||
// A field the request never mentioned must not materialize.
|
||||
expect(settings.builtInAgentOverrides.Explore.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears one field while the sibling override survives', async () => {
|
||||
await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
effort: 'high',
|
||||
})
|
||||
const cleared = await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: null,
|
||||
})
|
||||
|
||||
expect(cleared.status).toBe(200)
|
||||
expect(cleared.data.agent.effort).toBe('high')
|
||||
expect(cleared.data.agent.model).toBe(cleared.data.agent.defaults.model)
|
||||
|
||||
const settings = await readUserSettings()
|
||||
expect(settings.builtInAgentOverrides.Explore).toEqual({ effort: 'high' })
|
||||
})
|
||||
|
||||
it('removes the key entirely once the last override is gone', async () => {
|
||||
await writeUserSettings({ model: 'opus' })
|
||||
await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
})
|
||||
await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: null,
|
||||
})
|
||||
|
||||
const settings = await readUserSettings()
|
||||
expect(settings.model).toBe('opus')
|
||||
// No empty husk left behind in the user's file.
|
||||
expect('builtInAgentOverrides' in settings).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the shipped default on DELETE and stays idempotent', async () => {
|
||||
const shipped = (await getAgent('Explore')).defaults
|
||||
|
||||
await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
effort: 'high',
|
||||
})
|
||||
|
||||
const reset = await api(
|
||||
'DELETE',
|
||||
`/api/agents/Explore/override?cwd=${encodeURIComponent(projectCwd)}`,
|
||||
)
|
||||
expect(reset.status).toBe(200)
|
||||
expect(reset.data.agent.model).toBe(shipped.model)
|
||||
expect(reset.data.agent.effort).toBe(shipped.effort)
|
||||
expect(reset.data.agent.override).toBeUndefined()
|
||||
expect('builtInAgentOverrides' in (await readUserSettings())).toBe(false)
|
||||
|
||||
// Deleting something that is already absent is a no-op, not a 404 — the
|
||||
// desktop fires "reset" without checking first.
|
||||
const again = await api(
|
||||
'DELETE',
|
||||
`/api/agents/Explore/override?cwd=${encodeURIComponent(projectCwd)}`,
|
||||
)
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.data.agent.model).toBe(shipped.model)
|
||||
})
|
||||
|
||||
it('accepts a mixed-case built-in name the create route could never allow', async () => {
|
||||
// AGENT_SLUG_PATTERN is lowercase-only, so routing this through
|
||||
// assertValidName would make Explore and Plan permanently unreachable.
|
||||
const plan = await api('PUT', '/api/agents/Plan/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'opus',
|
||||
})
|
||||
expect(plan.status).toBe(200)
|
||||
expect(plan.data.agent.model).toBe('opus')
|
||||
})
|
||||
|
||||
it('refuses non-built-in targets without touching settings.json', async () => {
|
||||
await api('POST', '/api/agents', {
|
||||
scope: 'user',
|
||||
cwd: projectCwd,
|
||||
name: 'custom-agent',
|
||||
description: 'A user agent',
|
||||
systemPrompt: 'Prompt.',
|
||||
})
|
||||
const settingsBefore = await fs
|
||||
.readFile(path.join(configDir, 'settings.json'), 'utf-8')
|
||||
.catch(() => null)
|
||||
|
||||
const custom = await api('PUT', '/api/agents/custom-agent/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
})
|
||||
expect(custom.status).toBe(403)
|
||||
expect(custom.data.error).toBe('NOT_A_BUILT_IN_AGENT')
|
||||
|
||||
const missing = await api('PUT', '/api/agents/does-not-exist/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
|
||||
const settingsAfter = await fs
|
||||
.readFile(path.join(configDir, 'settings.json'), 'utf-8')
|
||||
.catch(() => null)
|
||||
expect(settingsAfter).toEqual(settingsBefore)
|
||||
})
|
||||
|
||||
it('rejects an invalid effort and an unexpected field', async () => {
|
||||
const badEffort = await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
effort: 'extreme',
|
||||
})
|
||||
expect(badEffort.status).toBe(400)
|
||||
expect(badEffort.data.message).toBe(
|
||||
'Agent effort must be a supported level or integer',
|
||||
)
|
||||
|
||||
// `scope` is refused rather than ignored: overrides are user-level, and
|
||||
// silently accepting a project scope would write somewhere else.
|
||||
const withScope = await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
scope: 'project',
|
||||
model: 'sonnet',
|
||||
})
|
||||
expect(withScope.status).toBe(400)
|
||||
expect(withScope.data.message).toBe('Unexpected agent field: scope')
|
||||
|
||||
expect('builtInAgentOverrides' in (await readUserSettings())).toBe(false)
|
||||
})
|
||||
|
||||
it('reloads the running CLI session once per override write', async () => {
|
||||
// The loader caches agent definitions per session, so writing the file is
|
||||
// only half the job — without the reload the session keeps the old model.
|
||||
const controlRequests: Array<Record<string, unknown>> = []
|
||||
conversationService.hasSession = ((sessionId: string) =>
|
||||
sessionId === 'session-override') as typeof conversationService.hasSession
|
||||
conversationService.requestControl = (async (
|
||||
_sessionId: string,
|
||||
request: Record<string, unknown>,
|
||||
) => {
|
||||
controlRequests.push(request)
|
||||
return { commands: [], agents: [], plugins: [], mcpServers: [] }
|
||||
}) as typeof conversationService.requestControl
|
||||
|
||||
await api('PUT', '/api/agents/Explore/override', {
|
||||
cwd: projectCwd,
|
||||
model: 'sonnet',
|
||||
})
|
||||
const reload = await api(
|
||||
'POST',
|
||||
'/api/agents/reload?sessionId=session-override',
|
||||
)
|
||||
|
||||
expect(reload.status).toBe(200)
|
||||
expect(reload.data.session.applied).toBe(true)
|
||||
expect(controlRequests).toEqual([{ subtype: 'reload_plugins' }])
|
||||
})
|
||||
})
|
||||
|
||||
async function api(
|
||||
method: string,
|
||||
requestPath: string,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type AgentMutationResult,
|
||||
type AgentScope,
|
||||
} from '../services/agentService.js'
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { taskService } from '../services/taskService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { resetTaskList } from '../../utils/tasks.js'
|
||||
@@ -37,12 +38,23 @@ import {
|
||||
} from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js'
|
||||
import { getBuiltInAgentsWithoutOverrides } from '../../tools/AgentTool/builtInAgents.js'
|
||||
import {
|
||||
resolveBuiltInAgentOverrides,
|
||||
type ResolvedBuiltInAgentOverride,
|
||||
} from '../../tools/AgentTool/builtInAgentOverrides.js'
|
||||
import { isRestrictedToPluginOnly } from '../../utils/settings/pluginOnlyPolicy.js'
|
||||
import {
|
||||
SETTING_SOURCES,
|
||||
type SettingSource,
|
||||
} from '../../utils/settings/constants.js'
|
||||
import { parseEffortValue } from '../../utils/effort.js'
|
||||
import { reloadSessionComponents } from '../services/sessionComponentReloadService.js'
|
||||
import { getAllBaseTools } from '../../tools.js'
|
||||
import { filterToolsForAgent } from '../../tools/AgentTool/agentToolUtils.js'
|
||||
|
||||
const agentService = new AgentService()
|
||||
const settingsService = new SettingsService()
|
||||
|
||||
export async function handleAgentsApi(
|
||||
req: Request,
|
||||
@@ -132,6 +144,43 @@ async function handleAgents(
|
||||
return Response.json({ agent: visibleAgent }, { status: 201 })
|
||||
}
|
||||
|
||||
// ── PUT|DELETE /api/agents/:name/override ────────────────────────────
|
||||
// A deliberately narrow path for built-in agents, which stay read-only on
|
||||
// the routes below. Placed ahead of them so the generic PUT/DELETE keep
|
||||
// seeing only `/api/agents/:name` and their 403 contract is untouched.
|
||||
if (segments[3] === 'override' && agentName) {
|
||||
if (method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
assertAllowedFields(body, BUILT_IN_OVERRIDE_FIELDS)
|
||||
const cwd = typeof body.cwd === 'string' ? body.cwd : getCwd()
|
||||
await assertOverridableBuiltInAgent(agentName, cwd)
|
||||
const patch = parseBuiltInOverridePatch(body)
|
||||
await settingsService.updateBuiltInAgentOverride(agentName, patch)
|
||||
clearAgentDefinitionsCache()
|
||||
return Response.json({
|
||||
agent: await loadBuiltInAgentAfterOverride(agentName, cwd),
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
const cwd = url.searchParams.get('cwd') || getCwd()
|
||||
await assertOverridableBuiltInAgent(agentName, cwd)
|
||||
// Idempotent: clearing an agent that has no override is not an error,
|
||||
// so the desktop can fire "reset to default" without checking first.
|
||||
await settingsService.updateBuiltInAgentOverride(agentName, null)
|
||||
clearAgentDefinitionsCache()
|
||||
return Response.json({
|
||||
agent: await loadBuiltInAgentAfterOverride(agentName, cwd),
|
||||
})
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed on /api/agents/${agentName}/override`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
|
||||
// ── PUT /api/agents/:name ────────────────────────────────────────────
|
||||
if (method === 'PUT' && agentName) {
|
||||
const body = await parseJsonBody(req)
|
||||
@@ -270,7 +319,22 @@ type ApiAgentDefinition = {
|
||||
baseDir?: string
|
||||
target?: string
|
||||
isActive: boolean
|
||||
/**
|
||||
* Whether the *file* backing this agent can be rewritten. Built-ins stay
|
||||
* false — the generic PUT/DELETE still 403 them. Deliberately separate from
|
||||
* `overridable` below, which is a much narrower capability.
|
||||
*/
|
||||
editable?: boolean
|
||||
/** Built-in agents only: model/effort can be changed via /override. */
|
||||
overridable?: boolean
|
||||
/** Built-in agents only: what model/effort this build ships with. */
|
||||
defaults?: { model?: string; effort?: SharedAgentDefinition['effort'] }
|
||||
/** Built-in agents only: the override currently in effect, if any. */
|
||||
override?: {
|
||||
model?: string
|
||||
effort?: SharedAgentDefinition['effort']
|
||||
source: SettingSource
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResolvedAgentDefinition = ApiAgentDefinition & {
|
||||
@@ -304,6 +368,51 @@ function serializeActiveAgent(
|
||||
target,
|
||||
isActive,
|
||||
editable,
|
||||
...serializeBuiltInOverride(agent),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in extras: what this build ships, and what the user changed.
|
||||
*
|
||||
* `model`/`effort`/`modelDisplay` above already carry the *effective* value —
|
||||
* the override is applied at the loader — so these fields exist only so the UI
|
||||
* can offer "reset to built-in default" and name the default. The default
|
||||
* differs per agent and per build, so it can never be a client-side constant.
|
||||
*/
|
||||
function serializeBuiltInOverride(
|
||||
agent: SharedAgentDefinition,
|
||||
): Partial<ApiAgentDefinition> {
|
||||
if (agent.source !== 'built-in') return {}
|
||||
|
||||
const shipped = getBuiltInAgentsWithoutOverrides().find(
|
||||
candidate => candidate.agentType === agent.agentType,
|
||||
)
|
||||
const override: ResolvedBuiltInAgentOverride | undefined =
|
||||
resolveBuiltInAgentOverrides().get(agent.agentType)
|
||||
const fields = [override?.model, override?.effort].filter(
|
||||
field => field !== undefined,
|
||||
)
|
||||
|
||||
return {
|
||||
overridable: true,
|
||||
defaults: { model: shipped?.model, effort: shipped?.effort },
|
||||
...(fields.length > 0
|
||||
? {
|
||||
override: {
|
||||
...(override?.model ? { model: override.model.value } : {}),
|
||||
...(override?.effort ? { effort: override.effort.value } : {}),
|
||||
// Fields can come from different files. Report the highest-priority
|
||||
// one so the UI locks the control if any part is admin-managed.
|
||||
source: fields.reduce((winner, field) =>
|
||||
SETTING_SOURCES.indexOf(field.source) >
|
||||
SETTING_SOURCES.indexOf(winner.source)
|
||||
? field
|
||||
: winner,
|
||||
).source,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +430,92 @@ const CREATE_AGENT_FIELDS = new Set([
|
||||
|
||||
const UPDATE_AGENT_FIELDS = new Set([...CREATE_AGENT_FIELDS, 'target'])
|
||||
|
||||
// No `scope`: built-in overrides are user-level by definition. Accepting and
|
||||
// ignoring one would silently write somewhere the caller did not ask for.
|
||||
const BUILT_IN_OVERRIDE_FIELDS = new Set(['model', 'effort', 'cwd'])
|
||||
|
||||
/**
|
||||
* Only the model and effort of an agent that is genuinely built-in right now.
|
||||
*
|
||||
* Membership, not `assertValidName`: AGENT_SLUG_PATTERN is lowercase-only and
|
||||
* would reject `Explore` and `Plan` outright.
|
||||
*/
|
||||
async function assertOverridableBuiltInAgent(
|
||||
name: string,
|
||||
cwd: string,
|
||||
): Promise<void> {
|
||||
if (isRestrictedToPluginOnly('agents')) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
'Agent customization is restricted to plugins by managed settings',
|
||||
'AGENT_CUSTOMIZATION_LOCKED',
|
||||
)
|
||||
}
|
||||
|
||||
const { allAgents } = await getAgentDefinitionsWithOverrides(cwd)
|
||||
const match = allAgents.find(agent => agent.agentType === name)
|
||||
if (!match) {
|
||||
throw ApiError.notFound(`Agent not found: ${name}`)
|
||||
}
|
||||
if (match.source !== 'built-in') {
|
||||
// Distinct from READ_ONLY_AGENT so the desktop can tell "edit this through
|
||||
// the agent editor" apart from "this source cannot be written at all".
|
||||
throw new ApiError(
|
||||
403,
|
||||
`Agent is not built-in: ${name}`,
|
||||
'NOT_A_BUILT_IN_AGENT',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseBuiltInOverridePatch(body: Record<string, unknown>): {
|
||||
model?: string | null
|
||||
effort?: string | number | null
|
||||
} {
|
||||
const patch: { model?: string | null; effort?: string | number | null } = {}
|
||||
|
||||
if (Object.hasOwn(body, 'model')) {
|
||||
patch.model =
|
||||
body.model === null ? null : requireNonEmptyString(body.model, 'model')
|
||||
}
|
||||
if (Object.hasOwn(body, 'effort')) {
|
||||
if (body.effort === null) {
|
||||
patch.effort = null
|
||||
} else {
|
||||
const effort = parseEffortValue(body.effort)
|
||||
if (effort === undefined) {
|
||||
throw ApiError.badRequest(
|
||||
'Agent effort must be a supported level or integer',
|
||||
)
|
||||
}
|
||||
patch.effort = effort
|
||||
}
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read a built-in agent after its override changed.
|
||||
*
|
||||
* loadMutatedAgent cannot serve this: it filters on the userSettings source and
|
||||
* matches by realpath of baseDir/sourceFilePath, neither of which a built-in has.
|
||||
*/
|
||||
async function loadBuiltInAgentAfterOverride(
|
||||
name: string,
|
||||
cwd: string,
|
||||
): Promise<ApiAgentDefinition> {
|
||||
const { activeAgents, allAgents } = await getAgentDefinitionsWithOverrides(cwd)
|
||||
const agent = allAgents.find(
|
||||
candidate => candidate.agentType === name && candidate.source === 'built-in',
|
||||
)
|
||||
if (!agent) {
|
||||
throw ApiError.internal(
|
||||
`Built-in agent disappeared after writing its override: ${name}`,
|
||||
)
|
||||
}
|
||||
return serializeAgentForRequest(agent, activeAgents.includes(agent), cwd)
|
||||
}
|
||||
|
||||
function decodeAgentName(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
|
||||
@@ -219,6 +219,58 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并单个内置 Agent 的 model/effort 覆盖。
|
||||
*
|
||||
* `patch` 为 null 时删除整条覆盖;字段值为 null 时只删该字段。
|
||||
* 条目清空后连同条目一起删除,`builtInAgentOverrides` 变空时删掉这个 key,
|
||||
* 不在用户的 settings.json 里留下空壳。
|
||||
*
|
||||
* 不走 updateUserSettings:那是顶层浅合并,传一个 builtInAgentOverrides 会
|
||||
* 整体替换 record。若在调用方先读再算差量,读改写就落在写锁之外,连点两个
|
||||
* Agent 会丢掉一次更新。
|
||||
*/
|
||||
async updateBuiltInAgentOverride(
|
||||
agentType: string,
|
||||
patch: {
|
||||
model?: string | null
|
||||
effort?: string | number | null
|
||||
} | null,
|
||||
): Promise<void> {
|
||||
const filePath = this.getUserSettingsPath()
|
||||
await this.withWriteLock(filePath, async () => {
|
||||
const current = await this.readJsonFile(filePath)
|
||||
const overrides = { ...(normalizeJsonObject(current.builtInAgentOverrides) ?? {}) }
|
||||
|
||||
if (patch === null) {
|
||||
delete overrides[agentType]
|
||||
} else {
|
||||
const entry = { ...(normalizeJsonObject(overrides[agentType]) ?? {}) }
|
||||
for (const field of ['model', 'effort'] as const) {
|
||||
if (!Object.hasOwn(patch, field)) continue
|
||||
if (patch[field] === null) {
|
||||
delete entry[field]
|
||||
} else {
|
||||
entry[field] = patch[field]
|
||||
}
|
||||
}
|
||||
if (Object.keys(entry).length === 0) {
|
||||
delete overrides[agentType]
|
||||
} else {
|
||||
overrides[agentType] = entry
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Object.assign({}, current)
|
||||
if (Object.keys(overrides).length === 0) {
|
||||
delete merged.builtInAgentOverrides
|
||||
} else {
|
||||
merged.builtInAgentOverrides = overrides
|
||||
}
|
||||
await this.writeJsonFile(filePath, merged)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { EFFORT_LEVELS } from '../../utils/effort.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
import { SettingsSchema } from '../../utils/settings/types.js'
|
||||
|
||||
// strictPluginOnlyCustomization comes from managed settings, whose path is
|
||||
// memoized without a cache key and cannot be redirected on a non-ant build.
|
||||
// Only the policy answer is faked; every other input below is a real file.
|
||||
let agentsSurfaceLocked = false
|
||||
const actualPolicy = await import('../../utils/settings/pluginOnlyPolicy.js')
|
||||
mock.module('../../utils/settings/pluginOnlyPolicy.js', () => ({
|
||||
...actualPolicy,
|
||||
isRestrictedToPluginOnly: (surface: string) =>
|
||||
surface === 'agents' ? agentsSurfaceLocked : false,
|
||||
}))
|
||||
|
||||
const { getBuiltInAgents, getBuiltInAgentsWithoutOverrides } = await import(
|
||||
'./builtInAgents.js'
|
||||
)
|
||||
const { BUILT_IN_AGENT_OVERRIDE_EFFORT_LEVELS, resolveBuiltInAgentOverrides } =
|
||||
await import('./builtInAgentOverrides.js')
|
||||
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
/** Write the user settings file the resolver reads, then clear the caches. */
|
||||
function writeUserSettings(settings: unknown): void {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'settings.json'),
|
||||
JSON.stringify(settings ?? {}),
|
||||
)
|
||||
resetSettingsCache()
|
||||
}
|
||||
|
||||
/** The shipped definition for an agent, whatever this build defaults it to. */
|
||||
function baseline(agentType: string) {
|
||||
const agent = getBuiltInAgentsWithoutOverrides().find(
|
||||
candidate => candidate.agentType === agentType,
|
||||
)
|
||||
if (!agent) throw new Error(`No built-in agent named ${agentType}`)
|
||||
return agent
|
||||
}
|
||||
|
||||
function effective(agentType: string) {
|
||||
const agent = getBuiltInAgents().find(
|
||||
candidate => candidate.agentType === agentType,
|
||||
)
|
||||
if (!agent) throw new Error(`No built-in agent named ${agentType}`)
|
||||
return agent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'built-in-agent-overrides-'))
|
||||
// getClaudeConfigHomeDir memoizes on the value of this var, so assigning it
|
||||
// is enough to redirect the read without clearing a cache.
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
agentsSurfaceLocked = false
|
||||
writeUserSettings({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
resetSettingsCache()
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('built-in agent overrides', () => {
|
||||
test('applies model and effort without mutating the shipped definitions', () => {
|
||||
const shippedModel = baseline('Explore').model
|
||||
const shippedEffort = baseline('Explore').effort
|
||||
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet', effort: 'low' } },
|
||||
})
|
||||
|
||||
const overridden = effective('Explore')
|
||||
expect(overridden.model).toBe('sonnet')
|
||||
expect(overridden.effort).toBe('low')
|
||||
expect(overridden.source).toBe('built-in')
|
||||
|
||||
// The shipped definitions are module-level constants that resumeAgent and
|
||||
// AgentTool reference directly. An in-place write would leak across every
|
||||
// later caller and could not be undone by removing the setting.
|
||||
expect(baseline('Explore').model).toBe(shippedModel)
|
||||
expect(baseline('Explore').effort).toBe(shippedEffort)
|
||||
|
||||
// …and applying twice must not accumulate.
|
||||
expect(effective('Explore').model).toBe('sonnet')
|
||||
})
|
||||
|
||||
test('leaves agents without an override at their original object identity', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet' } },
|
||||
})
|
||||
|
||||
const untouched = effective('general-purpose')
|
||||
expect(untouched).toBe(baseline('general-purpose'))
|
||||
})
|
||||
|
||||
test('returns to the shipped default when the override is removed', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet', effort: 'high' } },
|
||||
})
|
||||
expect(effective('Explore').model).toBe('sonnet')
|
||||
|
||||
writeUserSettings({})
|
||||
|
||||
// Compared against the runtime baseline, never a literal: Explore ships as
|
||||
// 'haiku' externally and 'inherit' for ants, so a hardcoded expectation
|
||||
// would be wrong in half the builds and would rot the day the default moves.
|
||||
expect(effective('Explore').model).toBe(baseline('Explore').model)
|
||||
expect(effective('Explore').effort).toBe(baseline('Explore').effort)
|
||||
})
|
||||
|
||||
test('clears one field while the other override survives', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet', effort: 'high' } },
|
||||
})
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { effort: 'high' } },
|
||||
})
|
||||
|
||||
expect(effective('Explore').model).toBe(baseline('Explore').model)
|
||||
expect(effective('Explore').effort).toBe('high')
|
||||
})
|
||||
|
||||
test('treats model "inherit" as a real override, not as a reset', () => {
|
||||
// Explore ships pinned to a small model externally, so "inherit" is a
|
||||
// meaningful and different choice. Collapsing it into "no override" would
|
||||
// make following the main session unreachable from the UI.
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'inherit' } },
|
||||
})
|
||||
|
||||
expect(effective('Explore').model).toBe('inherit')
|
||||
expect(resolveBuiltInAgentOverrides().get('Explore')?.model).toEqual({
|
||||
value: 'inherit',
|
||||
source: 'userSettings',
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves the system-prompt function reference and arity', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: {
|
||||
'claude-code-guide': { model: 'sonnet' },
|
||||
Explore: { model: 'sonnet' },
|
||||
},
|
||||
})
|
||||
|
||||
// serializeActiveAgent decides whether it may call getSystemPrompt with no
|
||||
// arguments by reading `.length`. claude-code-guide declares a parameter;
|
||||
// the others declare none. A wrapper would report 0 for both and the guide
|
||||
// agent would destructure undefined.
|
||||
const guide = effective('claude-code-guide')
|
||||
expect(guide.getSystemPrompt).toBe(baseline('claude-code-guide').getSystemPrompt)
|
||||
expect(guide.getSystemPrompt.length).toBe(1)
|
||||
|
||||
const explore = effective('Explore')
|
||||
expect(explore.getSystemPrompt).toBe(baseline('Explore').getSystemPrompt)
|
||||
expect(explore.getSystemPrompt.length).toBe(0)
|
||||
})
|
||||
|
||||
test('ignores an unknown agent and an invalid effort without dropping valid siblings', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: {
|
||||
Explorer: { model: 'opus' },
|
||||
Explore: { model: 'opus', effort: 'extreme' },
|
||||
Plan: { model: 'sonnet' },
|
||||
},
|
||||
})
|
||||
|
||||
const explore = effective('Explore')
|
||||
expect(explore.model).toBe('opus')
|
||||
expect(explore.effort).toBe(baseline('Explore').effort)
|
||||
expect(effective('Plan').model).toBe('sonnet')
|
||||
// The unknown key is kept on disk — the built-in set varies with feature
|
||||
// flags and entrypoint, so pruning it would destroy a valid config the
|
||||
// moment a flag flipped.
|
||||
expect(
|
||||
getBuiltInAgents().some(agent => agent.agentType === 'Explorer'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('keeps a numeric effort, which existing SDK configs still use', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { effort: 7 } },
|
||||
})
|
||||
|
||||
expect(effective('Explore').effort).toBe(7)
|
||||
})
|
||||
|
||||
test('drops user overrides when the agents surface is locked to plugins', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet' } },
|
||||
})
|
||||
agentsSurfaceLocked = true
|
||||
resetSettingsCache()
|
||||
|
||||
// settings.json is user-writable, so blocking only the write endpoint would
|
||||
// leave the policy trivially bypassable. It has to be enforced on read.
|
||||
expect(effective('Explore').model).toBe(baseline('Explore').model)
|
||||
expect(resolveBuiltInAgentOverrides().size).toBe(0)
|
||||
})
|
||||
|
||||
test('applies user overrides when no such policy is set', () => {
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet' } },
|
||||
})
|
||||
agentsSurfaceLocked = false
|
||||
resetSettingsCache()
|
||||
|
||||
expect(effective('Explore').model).toBe('sonnet')
|
||||
})
|
||||
|
||||
test('keeps the SDK blank-slate opt-out ahead of overrides', () => {
|
||||
const originalDisable = process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS
|
||||
process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS = 'true'
|
||||
writeUserSettings({
|
||||
builtInAgentOverrides: { Explore: { model: 'sonnet' } },
|
||||
})
|
||||
|
||||
try {
|
||||
expect(getBuiltInAgents()).toEqual([])
|
||||
} finally {
|
||||
if (originalDisable === undefined) {
|
||||
delete process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS
|
||||
} else {
|
||||
process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS = originalDisable
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('accepts every effort level the settings schema accepts', () => {
|
||||
// The schema inlines its level list to dodge an import cycle; this is the
|
||||
// join that proves the copy still matches the runtime source.
|
||||
expect(BUILT_IN_AGENT_OVERRIDE_EFFORT_LEVELS).toEqual(EFFORT_LEVELS)
|
||||
|
||||
for (const effort of EFFORT_LEVELS) {
|
||||
const parsed = SettingsSchema().parse({
|
||||
builtInAgentOverrides: { Explore: { effort } },
|
||||
})
|
||||
expect(parsed.builtInAgentOverrides?.Explore?.effort).toBe(effort)
|
||||
|
||||
writeUserSettings({ builtInAgentOverrides: { Explore: { effort } } })
|
||||
expect(effective('Explore').effort).toBe(effort)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { EFFORT_LEVELS, type EffortValue, parseEffortValue } from '../../utils/effort.js'
|
||||
import {
|
||||
getEnabledSettingSources,
|
||||
type SettingSource,
|
||||
} from '../../utils/settings/constants.js'
|
||||
import {
|
||||
isRestrictedToPluginOnly,
|
||||
isSourceAdminTrusted,
|
||||
} from '../../utils/settings/pluginOnlyPolicy.js'
|
||||
import { getSettingsForSource } from '../../utils/settings/settings.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
/**
|
||||
* `SettingsSchema` inlines its effort level list to avoid a
|
||||
* types -> effort -> settings -> types import cycle. Exported so
|
||||
* builtInAgentOverrides.test.ts can assert the copy has not drifted.
|
||||
*/
|
||||
export const BUILT_IN_AGENT_OVERRIDE_EFFORT_LEVELS = EFFORT_LEVELS
|
||||
|
||||
export type OverriddenField<T> = {
|
||||
value: T
|
||||
/** Which settings file supplied this value — drives editability in the UI. */
|
||||
source: SettingSource
|
||||
}
|
||||
|
||||
export type ResolvedBuiltInAgentOverride = {
|
||||
model?: OverriddenField<string>
|
||||
effort?: OverriddenField<EffortValue>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `builtInAgentOverrides` from every enabled settings source, lowest
|
||||
* priority first, recording per field which source won.
|
||||
*
|
||||
* Deliberately walks sources instead of reading the merged settings object.
|
||||
* The merged value would be identical, but the per-source walk buys two things
|
||||
* the merge cannot:
|
||||
* 1. Field-level source attribution, so the desktop can disable a control
|
||||
* whose value comes from a managed policy the user cannot edit.
|
||||
* 2. Load-time enforcement of `strictPluginOnlyCustomization`. That policy
|
||||
* already blocks `~/.claude/agents/*.md` in markdownConfigLoader; without
|
||||
* the same filter here, a hand-edited settings.json would be a way around
|
||||
* it. Blocking only the write path would not be enough — settings.json is
|
||||
* user-writable by definition.
|
||||
*/
|
||||
export function resolveBuiltInAgentOverrides(): Map<
|
||||
string,
|
||||
ResolvedBuiltInAgentOverride
|
||||
> {
|
||||
const resolved = new Map<string, ResolvedBuiltInAgentOverride>()
|
||||
const agentsLocked = isRestrictedToPluginOnly('agents')
|
||||
|
||||
for (const source of getEnabledSettingSources()) {
|
||||
if (agentsLocked && !isSourceAdminTrusted(source)) continue
|
||||
|
||||
const overrides = getSettingsForSource(source)?.builtInAgentOverrides
|
||||
if (!overrides) continue
|
||||
|
||||
for (const [agentType, entry] of Object.entries(overrides)) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
|
||||
// Re-validate rather than trusting the schema: policySettings can arrive
|
||||
// from MDM/registry/remote sync, which never passes through the file
|
||||
// parser that applies the per-field .catch().
|
||||
const model =
|
||||
typeof entry.model === 'string' && entry.model.trim().length > 0
|
||||
? entry.model.trim()
|
||||
: undefined
|
||||
const effort = parseEffortValue(entry.effort)
|
||||
if (model === undefined && effort === undefined) continue
|
||||
|
||||
const current = resolved.get(agentType) ?? {}
|
||||
// Later sources win, but only for the fields they actually specify — a
|
||||
// project-level model must not erase a user-level effort.
|
||||
if (model !== undefined) current.model = { value: model, source }
|
||||
if (effort !== undefined) current.effort = { value: effort, source }
|
||||
resolved.set(agentType, current)
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply resolved overrides onto built-in agent definitions.
|
||||
*
|
||||
* Two constraints that are load-bearing, not stylistic:
|
||||
*
|
||||
* - Never mutate in place, and only copy the entries that actually carry an
|
||||
* override. Call sites such as resumeAgent hold references to the exported
|
||||
* module constants, so untouched agents must come back with their original
|
||||
* object identity.
|
||||
* - Never wrap `getSystemPrompt`. serializeActiveAgent in the agents API
|
||||
* branches on `getSystemPrompt.length === 0` to decide whether it can call
|
||||
* the function with no arguments; CLAUDE_CODE_GUIDE_AGENT declares one
|
||||
* parameter and the other built-ins declare none. Any `(...args) => fn()`
|
||||
* wrapper reports length 0 for all of them and the guide agent then
|
||||
* destructures `undefined`. Spreading preserves both the reference and the
|
||||
* arity.
|
||||
*/
|
||||
export function applyBuiltInAgentOverrides(
|
||||
agents: AgentDefinition[],
|
||||
): AgentDefinition[] {
|
||||
const overrides = resolveBuiltInAgentOverrides()
|
||||
if (overrides.size === 0) return agents
|
||||
|
||||
return agents.map(agent => {
|
||||
const override = overrides.get(agent.agentType)
|
||||
if (!override?.model && !override?.effort) return agent
|
||||
|
||||
return {
|
||||
...agent,
|
||||
...(override.model ? { model: override.model.value } : {}),
|
||||
...(override.effort ? { effort: override.effort.value } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { feature } from 'bun:bundle'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { applyBuiltInAgentOverrides } from './builtInAgentOverrides.js'
|
||||
import { CLAUDE_CODE_GUIDE_AGENT } from './built-in/claudeCodeGuideAgent.js'
|
||||
import { EXPLORE_AGENT } from './built-in/exploreAgent.js'
|
||||
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
|
||||
@@ -32,7 +33,15 @@ function isVerificationAgentEnabled(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBuiltInAgents(): AgentDefinition[] {
|
||||
/**
|
||||
* The built-in agent set as shipped, before any user configuration is applied.
|
||||
*
|
||||
* Callers that need the effective definitions want `getBuiltInAgents()`. This
|
||||
* one exists so the agents API can report what "built-in default" means for a
|
||||
* given agent — that differs per agent and per build (Explore defaults to
|
||||
* haiku externally but inherit for ants), so it can never be hardcoded.
|
||||
*/
|
||||
export function getBuiltInAgentsWithoutOverrides(): AgentDefinition[] {
|
||||
// Allow disabling all built-in agents via env var (useful for SDK users who want a blank slate)
|
||||
// Only applies in noninteractive mode (SDK/API usage)
|
||||
if (
|
||||
@@ -80,3 +89,19 @@ export function getBuiltInAgents(): AgentDefinition[] {
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in agents with the user's `builtInAgentOverrides` applied.
|
||||
*
|
||||
* This is the single choke point every consumer of built-in agents goes
|
||||
* through (loadAgentsDir has the only three call sites), so applying the
|
||||
* override here makes the effective model reach spawning, `/agents`, and the
|
||||
* desktop list without any of them knowing an override exists.
|
||||
*
|
||||
* Applied after the early returns above on purpose: the SDK blank-slate opt-out
|
||||
* must stay empty, and coordinator mode exposes a different agentType set that
|
||||
* these overrides are not addressed to.
|
||||
*/
|
||||
export function getBuiltInAgents(): AgentDefinition[] {
|
||||
return applyBuiltInAgentOverrides(getBuiltInAgentsWithoutOverrides())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { EFFORT_LEVELS } from '../../effort.js'
|
||||
import { getSettingsForSource } from '../settings.js'
|
||||
import { resetSettingsCache } from '../settingsCache.js'
|
||||
import { SettingsSchema } from '../types.js'
|
||||
|
||||
// parseSettingsFileUncached returns `{ settings: null }` for the WHOLE file when
|
||||
// SettingsSchema rejects it, so a typo in one optional key would silently drop
|
||||
// the user's permissions, hooks and model. Every case here drives a real file
|
||||
// through the real reader rather than calling the schema directly, because the
|
||||
// discard happens in the reader, not in the schema.
|
||||
describe('builtInAgentOverrides settings schema', () => {
|
||||
let tmpDir: string
|
||||
let projectRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'built-in-overrides-'))
|
||||
projectRoot = path.join(tmpDir, 'project')
|
||||
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// `projectRootOverride` bypasses the per-source cache, but parseSettingsFile
|
||||
// keeps its own cache keyed by path — rewriting the same file without
|
||||
// clearing it replays the first parse. Production clears it on every write
|
||||
// (writeJsonFile -> resetSettingsCache), so do the same here.
|
||||
async function readSettings(raw: unknown) {
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, '.claude', 'settings.json'),
|
||||
typeof raw === 'string' ? raw : JSON.stringify(raw),
|
||||
)
|
||||
resetSettingsCache()
|
||||
return getSettingsForSource('projectSettings', projectRoot)
|
||||
}
|
||||
|
||||
it('parses a well-formed override for both fields', async () => {
|
||||
const settings = await readSettings({
|
||||
builtInAgentOverrides: {
|
||||
Explore: { model: 'sonnet', effort: 'low' },
|
||||
'general-purpose': { effort: 'high' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settings?.builtInAgentOverrides).toEqual({
|
||||
Explore: { model: 'sonnet', effort: 'low' },
|
||||
'general-purpose': { effort: 'high' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the rest of settings.json when the override key is malformed', async () => {
|
||||
const settings = await readSettings({
|
||||
model: 'opus',
|
||||
env: { FOO: '1' },
|
||||
builtInAgentOverrides: 'nope',
|
||||
})
|
||||
|
||||
// The whole point: a bad value degrades to "no overrides", it does not
|
||||
// take the file down with it.
|
||||
expect(settings).not.toBeNull()
|
||||
expect(settings?.model).toBe('opus')
|
||||
expect(settings?.env).toEqual({ FOO: '1' })
|
||||
expect(settings?.builtInAgentOverrides).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops only the invalid field, keeping its sibling on the same agent', async () => {
|
||||
const settings = await readSettings({
|
||||
model: 'opus',
|
||||
builtInAgentOverrides: {
|
||||
Explore: { model: 'sonnet', effort: 'extreme' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settings?.model).toBe('opus')
|
||||
expect(settings?.builtInAgentOverrides?.Explore?.model).toBe('sonnet')
|
||||
expect(settings?.builtInAgentOverrides?.Explore?.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops only the malformed agent entry, keeping its siblings', async () => {
|
||||
const settings = await readSettings({
|
||||
builtInAgentOverrides: {
|
||||
Explore: 'sonnet',
|
||||
Plan: { model: 'opus' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settings?.builtInAgentOverrides?.Explore).toEqual({})
|
||||
expect(settings?.builtInAgentOverrides?.Plan).toEqual({ model: 'opus' })
|
||||
})
|
||||
|
||||
it('accepts an empty-string model as absent rather than rejecting the file', async () => {
|
||||
const settings = await readSettings({
|
||||
model: 'opus',
|
||||
builtInAgentOverrides: { Explore: { model: ' ' } },
|
||||
})
|
||||
|
||||
expect(settings?.model).toBe('opus')
|
||||
expect(settings?.builtInAgentOverrides?.Explore?.model).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads a settings file written before the key existed', async () => {
|
||||
// Old fixture: no builtInAgentOverrides at all. Absent must stay absent —
|
||||
// never an empty object — so callers can tell "no overrides" from "{}".
|
||||
const settings = await readSettings({
|
||||
model: 'opus',
|
||||
permissions: { allow: ['Bash(git status)'] },
|
||||
})
|
||||
|
||||
expect(settings?.model).toBe('opus')
|
||||
expect(settings?.permissions?.allow).toEqual(['Bash(git status)'])
|
||||
expect(settings?.builtInAgentOverrides).toBeUndefined()
|
||||
expect('builtInAgentOverrides' in (settings ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts every agent-level effort level, including the ones session effortLevel omits', async () => {
|
||||
// Agent-level effort has no `max` gate — the session-level `effortLevel`
|
||||
// key does. Reading one from the other would make `xhigh`/`max` writable in
|
||||
// Markdown frontmatter but unselectable here.
|
||||
for (const effort of EFFORT_LEVELS) {
|
||||
const settings = await readSettings({
|
||||
builtInAgentOverrides: { Explore: { effort } },
|
||||
})
|
||||
expect(settings?.builtInAgentOverrides?.Explore?.effort).toBe(effort)
|
||||
}
|
||||
|
||||
// Integer efforts stay supported for existing SDK/JSON configs.
|
||||
const numeric = await readSettings({
|
||||
builtInAgentOverrides: { Explore: { effort: 7 } },
|
||||
})
|
||||
expect(numeric?.builtInAgentOverrides?.Explore?.effort).toBe(7)
|
||||
})
|
||||
|
||||
it('enumerates exactly the runtime effort levels', () => {
|
||||
// types.ts inlines the level list to avoid a
|
||||
// types -> effort -> settings -> types import cycle. This asserts the copy
|
||||
// did not drift from the runtime source.
|
||||
const parsed = SettingsSchema().parse({
|
||||
builtInAgentOverrides: Object.fromEntries(
|
||||
EFFORT_LEVELS.map(level => [level, { effort: level }]),
|
||||
),
|
||||
})
|
||||
for (const level of EFFORT_LEVELS) {
|
||||
expect(parsed.builtInAgentOverrides?.[level]?.effort).toBe(level)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -774,6 +774,42 @@ export const SettingsSchema = lazySchema(() =>
|
||||
'Name of an agent (built-in or custom) to use for the main thread. ' +
|
||||
"Applies the agent's system prompt, tool restrictions, and model.",
|
||||
),
|
||||
builtInAgentOverrides: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
// Any non-empty string, matching the Markdown `model` frontmatter
|
||||
// parser — third-party model IDs are not aliases.
|
||||
model: z.string().trim().min(1).optional().catch(undefined),
|
||||
// Agent-level effort takes the full level set, unlike the
|
||||
// session-level `effortLevel` above. Levels are inlined rather
|
||||
// than imported from utils/effort.js: that module reads settings,
|
||||
// so importing it here would close a types -> effort -> settings
|
||||
// -> types cycle. BUILT_IN_AGENT_OVERRIDE_EFFORT_LEVELS in
|
||||
// tools/AgentTool/builtInAgentOverrides.ts asserts they match.
|
||||
effort: z
|
||||
.union([
|
||||
z.enum(['low', 'medium', 'high', 'xhigh', 'max']),
|
||||
z.number().int(),
|
||||
])
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
})
|
||||
// Per-field .catch keeps one bad value from discarding its sibling;
|
||||
// passthrough leaves room for future per-agent fields.
|
||||
.passthrough()
|
||||
.catch({}),
|
||||
)
|
||||
.optional()
|
||||
// A malformed value here must not invalidate the whole settings file:
|
||||
// parseSettingsFileUncached drops every setting when the schema throws.
|
||||
.catch(undefined)
|
||||
.describe(
|
||||
'Per-agent model/effort overrides for built-in agents. Keys are the ' +
|
||||
'agentType as spawned (e.g. "Explore"), case-sensitive. Other ' +
|
||||
'built-in fields stay read-only.',
|
||||
),
|
||||
companyAnnouncements: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
|
||||
Reference in New Issue
Block a user