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
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,83 @@ describe('executeTaskCreate', () => {
);
});

it('uses automation default agent and model when the automation has no provider or model', async () => {
vi.mocked(appSettingsService.get).mockImplementation(async (key) => {
if (key === 'defaultAutomationAgent') return 'codex' as never;
if (key === 'defaultAutomationModel') return 'gpt-5-codex' as never;
return null as never;
});

await executeTaskCreate(
{
...automation,
conversationConfig: { prompt: 'Check things', provider: '', autoApprove: false },
},
run,
noopStep
);

expect(createConversation).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'codex',
model: 'gpt-5-codex',
})
);
});

it('keeps an automation-specific model ahead of the automation default model', async () => {
vi.mocked(appSettingsService.get).mockImplementation(async (key) => {
if (key === 'defaultAutomationAgent') return 'claude' as never;
if (key === 'defaultAutomationModel') return 'claude-default' as never;
return null as never;
});

await executeTaskCreate(
{
...automation,
conversationConfig: {
prompt: 'Check things',
provider: 'claude',
autoApprove: false,
model: 'claude-specific',
},
},
run,
noopStep
);

expect(createConversation).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'claude',
model: 'claude-specific',
})
);
});

it('does not apply a later automation default model to an automation with an explicit provider', async () => {
vi.mocked(appSettingsService.get).mockImplementation(async (key) => {
if (key === 'defaultAutomationAgent') return 'claude' as never;
if (key === 'defaultAutomationModel') return 'claude-default' as never;
return null as never;
});

await executeTaskCreate(
{
...automation,
conversationConfig: { prompt: 'Check things', provider: 'claude', autoApprove: false },
},
run,
noopStep
);

expect(createConversation).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'claude',
model: undefined,
})
);
});

it('creates ACP conversations with an initial queue and eagerly starts the session', async () => {
await executeTaskCreate(
{
Expand All @@ -345,6 +422,53 @@ describe('executeTaskCreate', () => {
expect(createConversation).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'claude',
model: 'sonnet',
initialQueue: [{ text: 'Check things' }],
isInitialConversation: true,
type: 'acp',
})
);
expect(vi.mocked(createConversation).mock.calls[0]?.[0].initialPrompt).toBeUndefined();
expect(acpRuntimeProcedures.startSession).toHaveBeenCalledWith({
input: expect.objectContaining({
conversationId: expect.any(String),
projectId: 'project-1',
taskId: expect.any(String),
providerId: 'claude',
workspaceId: 'workspace-1',
cwd: '/tmp/task',
sessionId: null,
model: 'sonnet',
initialQueue: [{ text: 'Check things' }],
}),
});
});

it('passes automation default model to ACP startup when no provider is configured', async () => {
vi.mocked(appSettingsService.get).mockImplementation(async (key) => {
if (key === 'defaultAutomationAgent') return 'claude' as never;
if (key === 'defaultAutomationModel') return 'sonnet' as never;
return null as never;
});

await executeTaskCreate(
{
...automation,
conversationConfig: {
prompt: 'Check things',
provider: '',
autoApprove: false,
type: 'acp',
},
},
run,
noopStep
);

expect(createConversation).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'claude',
model: 'sonnet',
initialQueue: [{ text: 'Check things' }],
isInitialConversation: true,
type: 'acp',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { createConversation } from '@main/core/conversations/createConversation'
import { issueController } from '@main/core/issues/controller';
import { openProject } from '@main/core/projects/operations/openProject';
import { projectManager } from '@main/core/projects/project-manager';
import { DEFAULT_AGENT_ID } from '@main/core/settings/settings-registry';
import { appSettingsService } from '@main/core/settings/settings-service';
import { generateRandom } from '@main/core/tasks/name-generation/generateTaskName';
import {
Expand Down Expand Up @@ -130,9 +129,18 @@ export async function executeTaskCreate(
}
const workspaceConfig = scopeWorkspaceConfigToRun(taskConfig.workspaceConfig, taskName);

const provider = (automation.conversationConfig?.provider ||
(await appSettingsService.get('defaultAgent')) ||
DEFAULT_AGENT_ID) as AgentProviderId;
// appSettingsService.get() resolves unset keys to registry defaults, so the automation
// agent default is never empty and needs no further fallback here.
const configuredProvider = automation.conversationConfig?.provider || null;
const provider = (configuredProvider ||
(await appSettingsService.get('defaultAutomationAgent'))) as AgentProviderId;
// Automation model defaults are intentionally independent from task/conversation defaults.
// They only apply when the automation has no explicit provider, so later default changes do
// not alter automations that were already configured with a specific agent.
const defaultAutomationModel = configuredProvider
? null
: await appSettingsService.get('defaultAutomationModel');
const model = automation.conversationConfig?.model || defaultAutomationModel || undefined;
const conversationType = automation.conversationConfig?.type ?? 'pty';
const initialQueue =
conversationType === 'acp'
Expand Down Expand Up @@ -239,7 +247,7 @@ export async function executeTaskCreate(
provider,
automation.conversationConfig?.autoApprove
),
model: automation.conversationConfig?.model || undefined,
model,
...(conversationType === 'acp' ? { initialQueue } : { initialPrompt: prompt }),
isInitialConversation: true,
type: conversationType,
Expand All @@ -254,7 +262,7 @@ export async function executeTaskCreate(
workspaceId: provision.data.workspaceId,
cwd: provision.data.path,
sessionId: null,
model: automation.conversationConfig?.model || null,
model: model ?? null,
initialQueue,
},
});
Expand Down
7 changes: 7 additions & 0 deletions apps/emdash-desktop/src/main/core/settings/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const themeSchema = z
.default(null);

export const defaultAgentSchema = z.optional(z.enum(AGENT_PROVIDER_IDS)).default(DEFAULT_AGENT_ID);
export const defaultModelSchema = z.string().nullable().optional().default(null);

export const keyboardSettingsSchema = z
.optional(
Expand Down Expand Up @@ -137,6 +138,9 @@ export const APP_SETTINGS_SCHEMA_MAP = {
project: projectSettingsSchema,
tasks: taskSettingsSchema,
defaultAgent: defaultAgentSchema,
defaultModel: defaultModelSchema,
defaultAutomationAgent: defaultAgentSchema,
defaultAutomationModel: defaultModelSchema,
keyboard: keyboardSettingsSchema,
notifications: notificationSettingsSchema,
theme: themeSchema,
Expand All @@ -154,6 +158,9 @@ export const appSettingsSchema = z.object({
project: projectSettingsSchema,
tasks: taskSettingsSchema,
defaultAgent: defaultAgentSchema,
defaultModel: defaultModelSchema,
defaultAutomationAgent: defaultAgentSchema,
defaultAutomationModel: defaultModelSchema,
keyboard: keyboardSettingsSchema,
notifications: notificationSettingsSchema,
theme: themeSchema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export const SETTINGS_DEFAULTS = {
},
theme: null,
defaultAgent: DEFAULT_AGENT_ID,
defaultModel: null,
defaultAutomationAgent: DEFAULT_AGENT_ID,
defaultAutomationModel: null,
keyboard: {},
openIn: {
default: 'terminal' as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ export function useAutomationFormState(

const initialConversation = useInitialConversationState(effectiveProjectId, seedProvider, false, {
resetPromptOnProjectChange: false,
defaultAgentSettingKey: 'defaultAutomationAgent',
defaultModelSettingKey: 'defaultAutomationModel',
applyDefaultModel: !seed,
});

const [promptSeeded, setPromptSeeded] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,22 @@ export function useAutomationSettingsAutoSave(automation: Automation) {
effectiveProjectId,
prompt,
provider,
model,
triggerConfig,
cronTz,
canSave,
buildTaskConfig,
name,
workspaceConfig,
} = formState;
const useChatUi = formState.initialConversation.useChatUi;

function buildConversationConfig(): ConversationConfig {
const useChatUi = formState.initialConversation.useChatUi;
return {
prompt: prompt.trim(),
provider,
autoApprove: false,
model: model ?? undefined,
type: useChatUi ? 'acp' : 'pty',
};
}
Expand Down Expand Up @@ -70,9 +72,9 @@ export function useAutomationSettingsAutoSave(automation: Automation) {
return;
}
if (canSave) savePatch();
// We intentionally only track provider here; other fields use action-at-change-site.
// We intentionally only track provider/model/type here; other fields use action-at-change-site.
// oxlint-disable-next-line react/exhaustive-deps
}, [provider]);
}, [provider, model, useChatUi]);

// Workspace config changes (preset, branch name, sandbox toggle, etc.) are not
// interceptable at the setter level because they go through useWorkspaceConfig
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { observer } from 'mobx-react-lite';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { conversationRegistry } from '@renderer/features/conversations/stores/conversation-registry';
import { getProjectSshConnectionId } from '@renderer/features/projects/stores/project-selectors';
import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key';
// TODO(conversations-extraction): Pass task settings into the modal instead of importing task hooks.
import { useTaskSettings } from '@renderer/features/tasks/hooks/useTaskSettings';
import { AgentSelector } from '@renderer/lib/components/agent-selector/agent-selector';
Expand Down Expand Up @@ -40,21 +41,30 @@ export const CreateConversationModal = observer(function CreateConversationModal
taskId: string;
}) {
const connectionId = getProjectSshConnectionId(projectId);
const { providerId, setProviderOverride, createDisabled } = useEffectiveProvider(connectionId);
const { providerId, defaultProviderId, setProviderOverride, createDisabled } =
useEffectiveProvider(connectionId);
const conversationMgr = conversationRegistry.get(taskId);
const taskSettings = useTaskSettings();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [autoApproveOverride, setAutoApproveOverride] = useState<boolean | null>(null);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [useAcpOverride, setUseAcpOverride] = useState(false);
const [modelTouched, setModelTouched] = useState(false);
const chatUiEnabled = useFeatureFlag('chat-ui');
useCloseGuard(isSubmitting);

const { data: agents } = useAgents();
const { value: defaultModelValue } = useAppSettingsKey('defaultModel');
const modelsCapability = agents?.find((a) => a.id === providerId)?.capabilities.models;
const modelOptions =
modelsCapability?.kind === 'selectable' ? modelsCapability.modelOptions : null;
const defaultModel =
providerId === defaultProviderId &&
typeof defaultModelValue === 'string' &&
modelOptions?.[defaultModelValue]
? defaultModelValue
: null;

const showAutoApproveToggle = providerId ? providerSupportsAutoApprove(providerId) : false;
const showAcpToggle = chatUiEnabled && providerId ? providerSupportsAcp(providerId) : false;
Expand All @@ -73,10 +83,15 @@ export const CreateConversationModal = observer(function CreateConversationModal
setProviderOverride(next);
setSelectedModel(null);
setUseAcpOverride(false);
setModelTouched(false);
},
[setProviderOverride]
);

useEffect(() => {
if (!modelTouched) setSelectedModel(defaultModel);
}, [defaultModel, modelTouched]);

const handleCreateConversation = useCallback(async () => {
if (createDisabled || isSubmitting || !conversationMgr || !providerId) return;
const id = crypto.randomUUID();
Expand Down Expand Up @@ -135,7 +150,10 @@ export const CreateConversationModal = observer(function CreateConversationModal
<FieldLabel>Model</FieldLabel>
<Select
value={selectedModel ?? ''}
onValueChange={(val) => setSelectedModel(val || null)}
onValueChange={(val) => {
setModelTouched(true);
setSelectedModel(val || null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Default model">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ import { resolveConversationProviderSelection } from './provider-selection';

export type EffectiveProvider = {
providerId: AgentProviderId | null;
defaultProviderId: AgentProviderId;
setProviderOverride: (id: AgentProviderId | null) => void;
createDisabled: boolean;
};

export function useEffectiveProvider(
connectionId?: string,
initialOverride?: AgentProviderId
initialOverride?: AgentProviderId,
defaultAgentSettingKey: 'defaultAgent' | 'defaultAutomationAgent' = 'defaultAgent'
): EffectiveProvider {
const [providerOverride, setProviderOverride] = useState<AgentProviderId | null>(
initialOverride ?? null
);

const { value: defaultAgentValue } = useAppSettingsKey('defaultAgent');
const { value: defaultAgentValue } = useAppSettingsKey(defaultAgentSettingKey);
const defaultProviderId: AgentProviderId = isValidProviderId(defaultAgentValue)
? defaultAgentValue
: 'claude';
Expand All @@ -42,5 +44,5 @@ export function useEffectiveProvider(
availabilityKnown,
});

return { providerId, setProviderOverride, createDisabled };
return { providerId, defaultProviderId, setProviderOverride, createDisabled };
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from '@renderer/lib/ui/button';
import { SearchInput } from '@renderer/lib/ui/search-input';
import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group';
import { CliAgentsList, type AgentFilter } from './CliAgentsList';
import { DefaultAgentSelector } from './DefaultAgentSelector';

export function AgentsSettingsPage() {
const [searchQuery, setSearchQuery] = useState('');
Expand All @@ -23,7 +24,6 @@ export function AgentsSettingsPage() {
return (
<>
<PageHeader sticky title="Agents" description="Manage agents and model configurations.">
{/* <DefaultAgentSelector /> */}
<div className="flex items-center justify-between gap-2">
<ToggleGroup
multiple={false}
Expand Down Expand Up @@ -56,6 +56,7 @@ export function AgentsSettingsPage() {
</div>
</PageHeader>
<div className="flex flex-col gap-3">
<DefaultAgentSelector />
<CliAgentsList searchQuery={searchQuery} filter={filter} onFilterChange={setFilter} />
</div>
</>
Expand Down
Loading
Loading