fix: start draft sessions from default model/agent and honor OpenCode default_agent

A new draft session inherited the previous session's model/agent instead of
resetting to defaults, because opening a draft restored the directory snapshot
without re-applying the startup default cascade. When the prior session ran in
a worktree, defaults were resolved against the worktree directory's provider
list, which omits project/global-scoped providers, so the default agent's model
fell back to opencode/big-pickle.

Resolve the default agent/model via a shared cascade (settings default ->
OpenCode default_agent -> build -> first), resolve the model from the agent's
pinned model/variant or OpenCode's config model, and activate the project's
config (not the worktree's) when opening a draft.
This commit is contained in:
Bohdan Triapitsyn
2026-06-14 21:36:05 +03:00
parent 9f06224151
commit 9111611bdc
3 changed files with 276 additions and 76 deletions
+3
View File
@@ -26,6 +26,9 @@ All notable changes to this project will be documented in this file.
- Notifications: notification streams now stay connected more reliably behind proxies (thanks to @kostazol).
- Mobile: the empty Changes view keeps a close control visible (thanks to @lilyzhaun).
- Security: file previews and downloads now reject paths outside the allowed workspace unless access has been granted.
- Sessions: fixed a bug where a running session would briefly flicker as idle (in the sidebar, the send/stop button, and the status row) when the app is protected by a password.
- Desktop: you can now open developer tools from the Help menu.
- Sessions: new draft sessions now start from the default model and agent instead of inheriting the previous session's selection, and fall back to OpenCode's own `default_agent` (and its model) when no OpenChamber default is set.
## [1.12.4] - 2026-06-11
+263 -75
View File
@@ -265,6 +265,128 @@ const resolveProviderModelSelection = ({
return null;
};
type DefaultAgentModelSelection = {
agentName: string | undefined;
providerId?: string;
modelId?: string;
variant?: string;
};
// Shared default-selection cascade used both at startup (loadAgents) and when opening a
// fresh draft (applyDefaultModelAgentSelection), so the two paths stay identical.
//
// Agent: settings.defaultAgent → opencode default_agent → build → first primary → first
// Model: settings.defaultModel → resolved agent's pinned model+variant → opencode config.model
// → opencode/big-pickle → first
//
// The opencode default_agent / default model (config fields on the OpenCode server) are honored
// only when our own settings have no valid default. OpenCode itself resolves a model the same way:
// an agent's pinned model wins, otherwise the global `model` config applies — so we check the
// agent's model before opencodeDefaultModel. When the agent supplies the model, its `variant` is
// carried through too (if the model actually exposes that variant).
const resolveDefaultAgentModelSelection = ({
agents,
providers,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
opencodeDefaultAgent,
opencodeDefaultModel,
}: {
agents: Agent[];
providers: ProviderWithModelList[];
settingsDefaultAgent?: string;
settingsDefaultModel?: string;
settingsDefaultVariant?: string;
opencodeDefaultAgent?: string;
opencodeDefaultModel?: string;
}): DefaultAgentModelSelection => {
if (agents.length === 0) {
return { agentName: undefined };
}
const resolveVariant = (providerId: string, modelId: string, variant?: string): string | undefined => {
if (!variant) {
return undefined;
}
const model = providers
.find((provider) => provider.id === providerId)
?.models.find((entry) => entry.id === modelId) as { variants?: Record<string, unknown> } | undefined;
return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, variant)
? variant
: undefined;
};
// --- Agent cascade ---
const primaryAgents = agents.filter((agent) => isPrimaryMode(agent.mode));
let resolvedAgent: Agent | undefined;
if (settingsDefaultAgent) {
resolvedAgent = agents.find((agent) => agent.name === settingsDefaultAgent);
}
if (!resolvedAgent && opencodeDefaultAgent) {
const candidate = agents.find((agent) => agent.name === opencodeDefaultAgent);
// OpenCode requires the default agent to be a visible primary agent.
if (candidate && isPrimaryMode(candidate.mode) && candidate.hidden !== true) {
resolvedAgent = candidate;
}
}
if (!resolvedAgent) {
resolvedAgent = primaryAgents.find((agent) => agent.name === "build") || primaryAgents[0] || agents[0];
}
if (!resolvedAgent) {
return { agentName: undefined };
}
// --- Model cascade ---
let providerId: string | undefined;
let modelId: string | undefined;
let variant: string | undefined;
if (settingsDefaultModel) {
const parsed = parseModelString(settingsDefaultModel);
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
providerId = parsed.providerId;
modelId = parsed.modelId;
variant = resolveVariant(providerId, modelId, settingsDefaultVariant);
}
}
if (!providerId
&& resolvedAgent.model?.providerID
&& resolvedAgent.model?.modelID
&& hasProviderModel(providers, resolvedAgent.model.providerID, resolvedAgent.model.modelID)) {
providerId = resolvedAgent.model.providerID;
modelId = resolvedAgent.model.modelID;
variant = resolveVariant(providerId, modelId, resolvedAgent.variant);
}
// OpenCode's global default model — used when neither our settings nor the agent pin a model.
if (!providerId && opencodeDefaultModel) {
const parsed = parseModelString(opencodeDefaultModel);
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
providerId = parsed.providerId;
modelId = parsed.modelId;
}
}
if (!providerId) {
if (hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
providerId = FALLBACK_PROVIDER_ID;
modelId = FALLBACK_MODEL_ID;
} else {
const firstProvider = providers[0];
const firstModel = firstProvider?.models[0];
if (firstProvider && firstModel) {
providerId = firstProvider.id;
modelId = firstModel.id;
}
}
}
return { agentName: resolvedAgent.name, providerId, modelId, variant };
};
const resolveGitGenerationModelSelection = ({
providers,
settingsZenModel,
@@ -652,6 +774,12 @@ interface ConfigStore {
settingsDefaultModel: string | undefined; // format: "provider/model"
settingsDefaultVariant: string | undefined;
settingsDefaultAgent: string | undefined;
// OpenCode server's own `default_agent` config field (name of a primary agent), used as a
// fallback when our own settingsDefaultAgent is unset. Sourced from opencodeClient.getConfig().
opencodeDefaultAgent: string | undefined;
// OpenCode server's own global `model` config field ("provider/model"), used as a fallback
// when neither our settingsDefaultModel nor the resolved agent pins a model.
opencodeDefaultModel: string | undefined;
settingsAutoCreateWorktree: boolean;
settingsGitmojiEnabled: boolean;
settingsDefaultFileViewerPreview: boolean;
@@ -730,6 +858,7 @@ interface ConfigStore {
cycleCurrentVariant: () => void;
getCurrentModelVariants: () => string[];
setAgent: (agentName: string | undefined) => void;
applyDefaultModelAgentSelection: () => void;
setSelectedProvider: (providerId: string) => void;
setSettingsDefaultModel: (model: string | undefined) => void;
setSettingsDefaultVariant: (variant: string | undefined) => void;
@@ -790,6 +919,8 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultModel: undefined,
settingsDefaultVariant: undefined,
settingsDefaultAgent: undefined,
opencodeDefaultAgent: undefined,
opencodeDefaultModel: undefined,
settingsAutoCreateWorktree: false,
settingsGitmojiEnabled: false,
settingsDefaultFileViewerPreview: false,
@@ -1573,17 +1704,24 @@ export const useConfigStore = create<ConfigStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
// Fetch agents and OpenChamber settings in parallel
const [agents, openChamberDefaults] = await Promise.all([
// Fetch agents, OpenChamber settings, and the OpenCode config in parallel.
// The OpenCode config is best-effort: a failure should not block agent
// loading, it just means we won't honor its default_agent this round.
const [agents, openChamberDefaults, opencodeConfig] = await Promise.all([
measureStartupTrace(
'loadAgents:api',
() => opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()),
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
),
fetchOpenChamberDefaults(),
opencodeClient
.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.getConfig())
.catch(() => null),
]);
const safeAgents = Array.isArray(agents) ? agents : [];
const opencodeDefaultAgent = normalizeOptionalString(opencodeConfig?.default_agent);
const opencodeDefaultModel = normalizeOptionalString(opencodeConfig?.model);
const providerLoad = _inFlightProviders.get(directoryKey);
if (providerLoad) {
@@ -1635,6 +1773,8 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultVariant: openChamberDefaults.defaultVariant,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
opencodeDefaultAgent,
opencodeDefaultModel,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false,
@@ -1727,82 +1867,45 @@ export const useConfigStore = create<ConfigStore>()(
return provider.models.some((m) => m.id === modelId);
};
// --- Agent Selection ---
// Priority: settings.defaultAgent → build → first primary → first agent
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0];
let resolvedAgent: Agent = fallbackAgent;
// Track invalid settings to clear
const invalidSettings: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } = {};
// 1. Check OpenChamber settings for default agent
if (openChamberDefaults.defaultAgent) {
const settingsAgent = safeAgents.find((agent) => agent.name === openChamberDefaults.defaultAgent);
if (settingsAgent) {
resolvedAgent = settingsAgent;
} else {
// Agent no longer exists - mark for clearing
invalidSettings.defaultAgent = '';
}
// Detect invalid OpenChamber settings so we can clear them from storage.
// This is independent of resolution: even though the cascade below falls
// back gracefully, stale settings pointing at removed agents/models/variants
// should be cleaned up.
const invalidSettings: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } = {};
if (openChamberDefaults.defaultAgent && !safeAgents.some((agent) => agent.name === openChamberDefaults.defaultAgent)) {
invalidSettings.defaultAgent = '';
}
// --- Model Selection ---
// Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle
let resolvedProviderId: string | undefined;
let resolvedModelId: string | undefined;
let resolvedVariant: string | undefined;
// 1. Check OpenChamber settings for default model
if (openChamberDefaults.defaultModel) {
const parsed = parseModelString(openChamberDefaults.defaultModel);
if (parsed && validateModel(parsed.providerId, parsed.modelId)) {
resolvedProviderId = parsed.providerId;
resolvedModelId = parsed.modelId;
if (openChamberDefaults.defaultVariant) {
const provider = providers.find((p) => p.id === parsed.providerId);
const model = provider?.models.find((m) => m.id === parsed.modelId) as { variants?: Record<string, unknown> } | undefined;
const variants = model?.variants;
if (variants && Object.prototype.hasOwnProperty.call(variants, openChamberDefaults.defaultVariant)) {
resolvedVariant = openChamberDefaults.defaultVariant;
} else {
invalidSettings.defaultVariant = '';
}
}
} else {
// Model no longer exists - mark for clearing
invalidSettings.defaultModel = '';
}
}
// 2. Fall back to agent's preferred model
if (!resolvedProviderId && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID) {
const { providerID, modelID } = resolvedAgent.model;
if (validateModel(providerID, modelID)) {
resolvedProviderId = providerID;
resolvedModelId = modelID;
}
}
// 3. Fall back to opencode/big-pickle
if (!resolvedProviderId) {
if (validateModel(FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
resolvedProviderId = FALLBACK_PROVIDER_ID;
resolvedModelId = FALLBACK_MODEL_ID;
} else {
// Last resort: first provider's first model
const firstProvider = providers[0];
const firstModel = firstProvider?.models[0];
if (firstProvider && firstModel) {
resolvedProviderId = firstProvider.id;
resolvedModelId = firstModel.id;
if (openChamberDefaults.defaultModel) {
const parsed = parseModelString(openChamberDefaults.defaultModel);
if (!parsed || !validateModel(parsed.providerId, parsed.modelId)) {
invalidSettings.defaultModel = '';
} else if (openChamberDefaults.defaultVariant) {
const provider = providers.find((p) => p.id === parsed.providerId);
const model = provider?.models.find((m) => m.id === parsed.modelId) as { variants?: Record<string, unknown> } | undefined;
const variants = model?.variants;
if (!(variants && Object.prototype.hasOwnProperty.call(variants, openChamberDefaults.defaultVariant))) {
invalidSettings.defaultVariant = '';
}
}
}
// Resolve agent + model via the shared cascade:
// settings.defaultAgent → opencode default_agent → build → first primary → first
// settings.defaultModel → resolved agent's model+variant → opencode/big-pickle → first
const resolvedDefault = resolveDefaultAgentModelSelection({
agents: safeAgents,
providers,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultVariant: openChamberDefaults.defaultVariant,
opencodeDefaultAgent,
opencodeDefaultModel,
});
const resolvedAgentName = resolvedDefault.agentName ?? safeAgents[0].name;
const resolvedProviderId = resolvedDefault.providerId;
const resolvedModelId = resolvedDefault.modelId;
const resolvedVariant = resolvedDefault.variant;
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers,
@@ -1819,7 +1922,7 @@ export const useConfigStore = create<ConfigStore>()(
...baseSnapshot,
providers,
agents: safeAgents,
currentAgentName: resolvedAgent.name,
currentAgentName: resolvedAgentName,
currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId,
currentModelId: resolvedModelId ?? baseSnapshot.currentModelId,
currentVariant: resolvedVariant,
@@ -1833,7 +1936,7 @@ export const useConfigStore = create<ConfigStore>()(
};
if (state.activeDirectoryKey === directoryKey) {
nextState.currentAgentName = resolvedAgent.name;
nextState.currentAgentName = resolvedAgentName;
if (resolvedProviderId && resolvedModelId) {
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
@@ -2090,6 +2193,91 @@ export const useConfigStore = create<ConfigStore>()(
}
},
// Re-applies the same priority cascade used at app startup (see loadAgents):
// agent: settings.defaultAgent → build → first primary → first agent
// model: settings.defaultModel → agent's preferred model → opencode/big-pickle → first
// Used when entering a fresh draft session so model/agent reset to defaults
// instead of sticking to the previously open session's selection.
applyDefaultModelAgentSelection: () => {
const {
agents,
providers,
settingsDefaultModel,
settingsDefaultVariant,
settingsDefaultAgent,
opencodeDefaultAgent,
opencodeDefaultModel,
} = get();
if (agents.length === 0 || providers.length === 0) {
return;
}
const {
agentName: resolvedAgentName,
providerId: resolvedProviderId,
modelId: resolvedModelId,
variant: resolvedVariant,
} = resolveDefaultAgentModelSelection({
agents,
providers,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
opencodeDefaultAgent,
opencodeDefaultModel,
});
if (!resolvedAgentName) {
return;
}
set((state) => {
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentVariant: state.currentVariant,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentAgentName: resolvedAgentName,
...(resolvedProviderId && resolvedModelId
? {
currentProviderId: resolvedProviderId,
currentModelId: resolvedModelId,
currentVariant: resolvedVariant,
selectedProviderId: resolvedProviderId,
}
: {}),
};
const nextState: Partial<ConfigStore> = {
currentAgentName: resolvedAgentName,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
},
};
if (resolvedProviderId && resolvedModelId) {
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
nextState.currentVariant = resolvedVariant;
nextState.selectedProviderId = resolvedProviderId;
}
return nextState;
});
},
setSettingsDefaultModel: (model: string | undefined) => {
set({ settingsDefaultModel: model });
},
+10 -1
View File
@@ -617,7 +617,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
useInputStore.getState().setPendingInputText(options.initialPrompt)
}
void activateConfigForDirectory(directory)
// Config (providers/agents/default model+agent) lives at the PROJECT level. When the user
// came from a worktree session, `directory` is the worktree path, whose provider list does
// not include project/global-scoped providers (e.g. the default agent's non-opencode model)
// — resolving defaults against it would wrongly fall back to opencode/big-pickle. Activate
// the project's config instead so the default cascade matches app startup, then re-apply it
// (a fresh draft must start from defaults, not inherit the previous session's selection).
const configDirectory = normalizePath(selectedProject?.path ?? null) ?? directory
void activateConfigForDirectory(configDirectory).then(() => {
useConfigStore.getState().applyDefaultModelAgentSelection()
})
},
// ---------------------------------------------------------------------------