Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/510.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Settings tells you which model does your quick background jobs when Claude Code is set for them.** It used to warn
that new memories and summaries wouldn't work, even though Sentient hands those jobs to another model you have set up.
Now it names that model. The warning stays as it was when there is no other model.
28 changes: 20 additions & 8 deletions desktop/src/features/models/ClaudeCodeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useConfigEditor } from '@/hooks/config'
import { useClaudeCodeStatus, useHardware, useLocalModels, useSetRoles } from '@/hooks/models'
import { qk } from '@/hooks/queryKeys'
import { api, errorMessage } from '@/lib/api'
import { claudeCodeState, isClaudeCode, otherModel, refusingRoles } from '@/lib/claudeCode'
import { claudeCodeState, fastStandIns, fixableRoles, isClaudeCode, otherModel, refusingRoles } from '@/lib/claudeCode'
import { modelShortName, recommendFastLocal, recommendLocal, ROLE_META } from '@/lib/models'
import type { RoleName } from '@/lib/types'

Expand Down Expand Up @@ -203,7 +203,11 @@ export function ClaudeCodeRoleWarning() {
const roles = config.models.roles as unknown as Record<string, string | null>
const refusing = refusingRoles(roles, status.data?.roles)
if (!refusing.length) return null
const chat = refusing.filter((r) => r.role !== 'embedding')
// the engine does the fast role's quick jobs on another model when it can (#474): say which, not that they fail
const [standIn] = fastStandIns(roles, config.models.fallbacks, status.data?.roles)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const stuck = standIn ? refusing.filter((r) => r.role !== 'fast') : refusing
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// the button changes only what is still stuck: a fast role the stand-in covers keeps its model
const chat = fixableRoles(stuck)
const rec = hardware.data?.recommendation
const installed = (local.data?.ollama.models ?? []).map((m) => m.name)
const recInstalled = rec && !rec.cloud_first && installed.some((n) => n === rec.name || n === `${rec.name}:latest`)
Expand All @@ -219,8 +223,8 @@ export function ClaudeCodeRoleWarning() {
local.data ? recommendFastLocal(local.data) : null
])
const label = (r: { role: string }) => ROLE_META[r.role as RoleName].label
const own = refusing.filter((r) => !r.inherited)
const inherited = refusing.filter((r) => r.inherited)
const own = stuck.filter((r) => !r.inherited)
const inherited = stuck.filter((r) => r.inherited)
const parts = [
own.length ? `${listText(own.map(label))} ${own.length === 1 ? 'is' : 'are'} set to it` : '',
inherited.length ? `${listText(inherited.map(label))} ${inherited.length === 1 ? 'gets' : 'get'} it from your main model` : ''
Expand All @@ -233,10 +237,18 @@ export function ClaudeCodeRoleWarning() {

return (
<Alert tone="warning" icon={<IconAlertTriangle />} title="Some work can't run on Claude Code">
<p>
Claude Code only answers your chats, but {parts.join(', and ')}. Until {refusing.length === 1 ? 'it uses' : 'they use'} another model,{' '}
{consequences(refusing.map((r) => r.role))} won&apos;t work. You can pick one under Roles.
</p>
{stuck.length > 0 && (
<p>
Claude Code only answers your chats, but {parts.join(', and ')}. Until {stuck.length === 1 ? 'it uses' : 'they use'} another model,{' '}
{consequences(stuck.map((r) => r.role))} won&apos;t work. You can pick one under Roles.
</p>
)}
{standIn && (
<p>
Claude Code can&apos;t do quick background jobs, so {standIn} does them.
{stuck.length === 0 && ' You can pick another model for them under Roles.'}
</p>
)}
{fix && chat.length > 0 && (
<Button size="sm" variant="secondary" className="mt-2.5" loading={setRoles.isPending} onClick={useFix}>
Use {modelShortName(fix)} for {chat.length === 1 ? ROLE_META[chat[0].role as RoleName].label.toLowerCase() : 'these'}
Expand Down
29 changes: 29 additions & 0 deletions desktop/src/lib/claudeCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,40 @@ export function refusingRoles(roles: Record<string, string | null | undefined>,
return out
}

/**
* The roles the "use another model" button changes: the ones still stuck, never the embedding role (it needs an
* embedding model, not a chat one). A fast role the engine covers with a stand-in is not stuck, so it keeps its setting.
*/
export function fixableRoles(stuck: readonly RefusingRole[]): RefusingRole[] {
return stuck.filter((r) => r.role !== 'embedding')
}

/** The first model that isn't Claude Code, for the work Claude Code refuses. '' when there is none. */
export function otherModel(candidates: readonly (string | null | undefined)[]): string {
return candidates.find((m): m is string => !!m && !isClaudeCode(m)) ?? ''
}

/**
* The models that do the fast role's quick jobs (memory decisions and extraction, summaries, chat titles) while every
* model it has is Claude Code, in the engine's order: the main model's fallbacks, the executor, the planner, the main
* model, never Claude Code. [] when the engine keeps the refusal: Claude Code does everything (`accepting` has `fast`),
* the fast role has another model of its own, or no other model is set up.
*
* This mirrors `stand_ins` in `sentient/llm/claude_code.py` (#474); change them together so Settings names the model
* the engine uses.
*/
export function fastStandIns(
roles: Record<string, string | null | undefined>,
fallbacks: Record<string, readonly string[] | undefined>,
accepting: readonly string[] | undefined
): string[] {
if (!accepting || accepting.includes('fast')) return []
const chain = [roles.fast || roles.primary, ...(fallbacks.fast ?? [])]
if (!chain[0] || !chain.every(isClaudeCode)) return []
const candidates = [...(fallbacks.primary ?? []), roles.executor, roles.planner, roles.primary]
return [...new Set(candidates.filter((m): m is string => !!m && !isClaudeCode(m)))]
}

/** What Settings shows next to Claude Code: off, chats only, or everything. */
export function claudeCodeState(enabled: boolean, everywhere: boolean): { label: string; tone: 'neutral' | 'warning' | 'danger' } {
if (!enabled) return { label: 'Off', tone: 'neutral' }
Expand Down
34 changes: 33 additions & 1 deletion desktop/tests/claudeCode.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Claude through your own Claude Code (#246): the status asked for right after the switch changes.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { allowedForRole, claudeCodeState, claudeWayFor, isClaudeCode, otherModel, refusingRoles, retryWhileSaving, SAVE_RETRIES, statusOnceSaved, SwitchNotSavedYet } from '../src/lib/claudeCode.ts'
import { allowedForRole, claudeCodeState, claudeWayFor, fastStandIns, fixableRoles, isClaudeCode, otherModel, refusingRoles, retryWhileSaving, SAVE_RETRIES, statusOnceSaved, SwitchNotSavedYet } from '../src/lib/claudeCode.ts'

test('a status from before the switch was saved is asked for again', async () => {
const answers = [
Expand Down Expand Up @@ -50,6 +50,38 @@ test('the background model is the first one that is not Claude Code', () => {
assert.equal(isClaudeCode('anthropic/claude-sonnet-5-5'), false)
})

// #510: the model that does the fast role's quick jobs while it is only on Claude Code (`stand_ins` in the engine, #474)
test('the fast role stands in on another model, in the engine order', () => {
const roles = { primary: 'claude-code/sonnet', fast: 'claude-code/haiku', planner: 'ollama_chat/qwen3:14b', executor: 'ollama_chat/qwen3:8b', voice: null }
assert.deepEqual(fastStandIns(roles, {}, ACCEPTING), ['ollama_chat/qwen3:8b', 'ollama_chat/qwen3:14b']) // executor, planner
const fallbacks = { primary: ['claude-code/opus', 'openai/gpt-5-mini', 'ollama_chat/qwen3:8b'] }
// the main model's fallbacks come first; never Claude Code, never twice
assert.deepEqual(fastStandIns(roles, fallbacks, ACCEPTING), ['openai/gpt-5-mini', 'ollama_chat/qwen3:8b', 'ollama_chat/qwen3:14b'])
assert.deepEqual(fastStandIns({ ...roles, planner: null, executor: '' }, {}, ACCEPTING), [])
// an empty fast role uses the main model; a main model that is not Claude Code is a stand-in too
assert.deepEqual(fastStandIns({ ...roles, fast: null }, {}, ACCEPTING), ['ollama_chat/qwen3:8b', 'ollama_chat/qwen3:14b'])
assert.deepEqual(fastStandIns({ primary: 'openai/gpt-5-mini', fast: 'claude-code/haiku' }, {}, ACCEPTING), ['openai/gpt-5-mini'])
})

test('the fast role keeps the refusal when the engine does not stand in', () => {
const roles = { primary: 'claude-code/sonnet', fast: 'claude-code/haiku', planner: null, executor: 'ollama_chat/qwen3:8b' }
assert.deepEqual(fastStandIns(roles, { fast: ['claude-code/opus'] }, ACCEPTING), ['ollama_chat/qwen3:8b'])
assert.deepEqual(fastStandIns(roles, { fast: ['claude-code/opus', 'openai/gpt-5-mini'] }, ACCEPTING), []) // it has a model of its own
assert.deepEqual(fastStandIns({ ...roles, fast: 'ollama_chat/qwen3:8b' }, {}, ACCEPTING), [])
assert.deepEqual(fastStandIns(roles, {}, [...ACCEPTING, 'fast']), []) // Claude Code does everything
assert.deepEqual(fastStandIns(roles, {}, undefined), []) // the engine hasn't said yet
assert.deepEqual(fastStandIns({ primary: '', fast: null }, {}, ACCEPTING), [])
})

test('the fix button changes only the roles still stuck, never embedding', () => {
const roles = { primary: 'claude-code/sonnet', fast: 'claude-code/haiku', planner: null, executor: 'ollama_chat/qwen3:8b', embedding: 'claude-code/opus' }
const refusing = refusingRoles(roles, ACCEPTING)
assert.deepEqual(fixableRoles(refusing).map((r) => r.role), ['fast', 'planner'])
// the fast role has a stand-in, so it is no longer stuck and keeps its model (#510)
assert.deepEqual(fixableRoles(refusing.filter((r) => r.role !== 'fast')).map((r) => r.role), ['planner'])
assert.deepEqual(fixableRoles(refusing.filter((r) => r.role === 'embedding')), []) // nothing to change: no button
})

test('Connect Claude starts on the choice that is set up now (#444)', () => {
assert.equal(claudeWayFor(undefined, ''), 'key')
assert.equal(claudeWayFor({ experimental_claude_code: true }, 'anthropic/claude-sonnet-5-5'), 'key')
Expand Down
3 changes: 2 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,8 @@ accept or loosen a rule.
model: `fallbacks.primary`, then the executor, the planner, the main model (#474, `claude_code.stand_ins`); the log
says `the fast model <model> can't do background work ..., so <models> does its jobs` once. Not inside a reply someone
waits for (the second look asks Claude Code then) or for the rules-from-chat check, which asks the chat's model
when the fast role refuses (#311). The check-up and Settings still flag the fast role. With no other model the
when the fast role refuses (#311). The check-up still flags the fast role. Settings names the model that does its jobs (the window works
it out from the saved roles in the same order, `fastStandIns`, #510) and keeps the plain warning when there is none. With no other model the
refusal stands. The refusal names the setting to change for the `planner` role ("...so it can't plan tasks. Pick a planner
model in Settings > Models.") and the `executor` role ("...Pick an executor model..."). It is a `ModelRefused`
(a `ProviderError`); when every model of a role refused, that sentence is the error as is, so a task that fails
Expand Down
5 changes: 4 additions & 1 deletion sentient/llm/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,10 @@ def stand_ins(config: SentientConfig, role: str, chain: list[str]) -> list[str]:
Code (and Claude Code not doing everything) they were refused, so a memory was always added, never updated or
skipped. They go to the other models the user set up instead, tried in turn: the main model's fallbacks, the
executor, the planner, the main model. Never the chat's own model: these jobs are background work (ADR 0022). Not inside a
reply someone waits for (``attended``: the second look asks Claude Code then) or ``without_stand_in``."""
reply someone waits for (``attended``: the second look asks Claude Code then) or ``without_stand_in``.

Settings names the same model in its Claude Code warning (``fastStandIns`` in ``desktop/src/lib/claudeCode.ts``); change
the two together (#510)."""
if role not in STAND_IN_ROLES or not chain or any(m.split("/", 1)[0] != PREFIX for m in chain):
return []
if is_attended() or _no_stand_in.get(): # part of a reply Claude Code may answer, or a check with its own way on
Expand Down
Loading