Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2828a72
Split exec subagent AI defaults
ibetitsmike Apr 30, 2026
67b6064
🤖 feat: add exec subagent defaults settings
ibetitsmike Apr 30, 2026
fdcd885
🤖 docs: explain agent AI defaults by run context
ibetitsmike Apr 30, 2026
467937a
chore: regenerate built-in skill content for agents docs
ibetitsmike Apr 30, 2026
471d524
refactor(settings): rename "Exec as subagent" to "Exec"
ibetitsmike May 1, 2026
00084db
🤖 refactor(config): extract legacy subagent mirror helpers to common
ibetitsmike May 1, 2026
d4ad14b
🤖 refactor(types): remove dead normalized agent id guards
ibetitsmike May 1, 2026
f90be84
🤖 tests(stories): assert dual Exec rows in TasksSection story
ibetitsmike May 1, 2026
1711e5f
🤖 fix(task-tool): stop forwarding parent MUX_MODEL_STRING to sub-agents
ibetitsmike May 1, 2026
3b647e8
🤖 fix(stories): drop brittle Exec row count assertion
ibetitsmike May 1, 2026
32c22d8
🤖 fix(settings): clear mirrored subagent default when agent entry is …
ibetitsmike May 1, 2026
30cf9bd
🤖 fix(chat-input): show workspace AI settings on first paint to preve…
ibetitsmike May 1, 2026
d471c8b
🤖 tests(thinking-context): mock useWorkspaceContext to stabilize meta…
ibetitsmike May 1, 2026
1a549c2
Clamp Exec subagent inherited thinking hint
ibetitsmike May 1, 2026
0699236
Add task AI precedence tests
ibetitsmike May 1, 2026
2aadd23
🤖 fix: preserve usage caches during exec split
ibetitsmike May 1, 2026
960e134
fix task runtime ai fallback
ibetitsmike May 1, 2026
2c4ff2a
Fix thinking model fallback
ibetitsmike May 1, 2026
2dd8fcb
🤖 fix: omit unchanged subagent defaults from saves
ibetitsmike May 1, 2026
23d9e1f
🤖 tests: cover custom subagent default fixture
ibetitsmike May 1, 2026
00435d1
🤖 fix(tests): stop ThinkingContext from globally mocking WorkspaceCon…
ibetitsmike May 1, 2026
c797ba1
🤖 fix(settings): drop stale mirrored subagent entry
ibetitsmike May 1, 2026
1afb857
🤖 fix(thinking): align cycle handler model source
ibetitsmike May 1, 2026
98d9b60
🤖 fix(router): preserve agent enable flags when pruning mirrored suba…
ibetitsmike May 1, 2026
00e2109
🤖 tests(thinking-context): stabilize CI timeouts for metadata fallbac…
ibetitsmike May 1, 2026
aaffe0b
🤖 tests(thinking-context): isolate metadata tests from mock leaks
ibetitsmike May 1, 2026
d517bb1
🤖 fix: honor subagent defaults in plan handoff
ibetitsmike May 1, 2026
b21564f
🤖 fix: address DEREM-24/25/26 review nits
ibetitsmike May 1, 2026
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
11 changes: 11 additions & 0 deletions docs/agents/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ task({

Only agents with `subagent.runnable: true` can be used this way.

### Run-context AI defaults

The same agent identity can use different default model and thinking settings depending on how it runs:

- **UI defaults** (`agentAiDefaults`) apply when you select the agent directly in the UI, such as choosing Exec in the chat input.
- **Subagent defaults** (`subagentAiDefaults`) apply when that agent is spawned through the `task` tool.

Subagent defaults inherit from UI defaults per field. If the subagent model is unset, Mux uses the matching UI agent model; if subagent thinking is unset, Mux uses the matching UI agent thinking level. You can override one subagent field and keep the other inherited.

Mux resolves the subagent model and thinking level when the `task` call creates the child workspace. Those resolved values are stored with that child workspace, so changing defaults later affects future subagent tasks only.

## Examples

### Security Audit Agent
Expand Down
204 changes: 204 additions & 0 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ import { act, cleanup, render, waitFor } from "@testing-library/react";
import React from "react";
import { ThinkingProvider } from "./ThinkingContext";
import { APIProvider, type APIClient } from "@/browser/contexts/API";
import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext";
import { ProjectProvider } from "@/browser/contexts/ProjectContext";
import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext";
import { RouterProvider } from "@/browser/contexts/RouterContext";
import { useWorkspaceContext, WorkspaceProvider } from "@/browser/contexts/WorkspaceContext";
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import {
getModelKey,
getProjectScopeId,
getThinkingLevelByModelKey,
getThinkingLevelKey,
} from "@/common/constants/storage";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import type { RecursivePartial } from "@/browser/testUtils";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";

Expand Down Expand Up @@ -43,10 +50,133 @@ const TestComponent: React.FC<TestProps> = (props) => {
);
};

const agentContextValue: AgentContextValue = {
agentId: "exec",
setAgentId: () => undefined,
currentAgent: undefined,
agents: [],
loaded: true,
loadFailed: false,
refresh: () => Promise.resolve(),
refreshing: false,
disableWorkspaceAgents: false,
setDisableWorkspaceAgents: () => undefined,
};

const SendOptionsComponent: React.FC<{ workspaceId: string }> = (props) => {
const options = useSendMessageOptions(props.workspaceId);
return <div data-testid="base-model">{options.baseModel}</div>;
};

function renderWithAPI(children: React.ReactNode) {
return render(<APIProvider client={currentClientMock as APIClient}>{children}</APIProvider>);
}

function createWorkspaceMetadata(
overrides: Partial<FrontendWorkspaceMetadata> & Pick<FrontendWorkspaceMetadata, "id">
): FrontendWorkspaceMetadata {
return {
projectPath: "/tmp/project",
projectName: "project",
name: "main",
namedWorkspacePath: "/tmp/project/main",
createdAt: "2026-01-01T00:00:00.000Z",
runtimeConfig: { type: "local", srcBaseDir: "/tmp/.mux/src" },
...overrides,
};
}

function createEmptyAsyncIterable<T>(): AsyncIterable<T> {
return {
async *[Symbol.asyncIterator](): AsyncIterator<T> {
await Promise.resolve();
if (Date.now() < 0) yield undefined as T;
},
};
}

function WorkspaceMetadataGate(props: {
workspaceId: string;
modelOverride?: string | null;
thinkingOverride?: "off" | null;
children: React.ReactNode;
}) {
const { workspaceMetadata } = useWorkspaceContext();
if (!workspaceMetadata.has(props.workspaceId)) {
return null;
}

if (props.modelOverride !== undefined) {
if (props.modelOverride == null) {
window.localStorage.removeItem(getModelKey(props.workspaceId));
} else {
updatePersistedState(getModelKey(props.workspaceId), props.modelOverride);
}
}

if (props.thinkingOverride == null) {
window.localStorage.removeItem(getThinkingLevelKey(props.workspaceId));
} else {
updatePersistedState(getThinkingLevelKey(props.workspaceId), props.thinkingOverride);
}

return <>{props.children}</>;
}

function createWorkspaceClient(metadata: FrontendWorkspaceMetadata): APIClient {
return {
workspace: {
list: () => Promise.resolve([metadata]),
onMetadata: () => Promise.resolve(createEmptyAsyncIterable()),
onChat: () => Promise.resolve(createEmptyAsyncIterable()),
getSessionUsage: () => Promise.resolve(undefined),
updateAgentAISettings: mock(() =>
Promise.resolve({ success: true as const, data: undefined })
),
activity: {
list: () => Promise.resolve({}),
subscribe: () => Promise.resolve(createEmptyAsyncIterable()),
},
truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }),
interruptStream: () => Promise.resolve({ success: true as const, data: undefined }),
},
projects: {
list: () => Promise.resolve([]),
listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }),
secrets: {
get: () => Promise.resolve([]),
},
},
} as unknown as APIClient;
}

function renderWithWorkspaceMetadata(props: {
workspaceId: string;
metadata: FrontendWorkspaceMetadata;
modelOverride?: string | null;
thinkingOverride?: "off" | null;
children: React.ReactNode;
}) {
const client = createWorkspaceClient(props.metadata);
return render(
<APIProvider client={client}>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<WorkspaceMetadataGate
workspaceId={props.workspaceId}
modelOverride={props.modelOverride}
thinkingOverride={props.thinkingOverride}
>
{props.children}
</WorkspaceMetadataGate>
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
</APIProvider>
);
}

describe("ThinkingContext", () => {
// Make getDefaultModel deterministic.
// (getDefaultModel reads from the global "model-default" localStorage key.)
Expand All @@ -70,6 +200,80 @@ describe("ThinkingContext", () => {
currentClientMock = {};
});

test("uses metadata model before global default but keeps explicit model", async () => {
const cases = [
{ workspaceId: "ws-model-metadata", override: null, expected: "openai:gpt-5.5" },
{
workspaceId: "ws-model-explicit",
override: "anthropic:explicit-model",
expected: "anthropic:explicit-model",
},
];

for (const testCase of cases) {
const metadata = createWorkspaceMetadata({
id: testCase.workspaceId,
aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" },
});
const view = renderWithWorkspaceMetadata({
workspaceId: testCase.workspaceId,
metadata,
modelOverride: testCase.override,
children: (
<ProviderOptionsProvider>
<AgentProvider value={agentContextValue}>
<ThinkingProvider workspaceId={testCase.workspaceId}>
<SendOptionsComponent workspaceId={testCase.workspaceId} />
</ThinkingProvider>
</AgentProvider>
</ProviderOptionsProvider>
),
});

await waitFor(() => {
expect(view.getByTestId("base-model").textContent).toBe(testCase.expected);
});
cleanup();
}
});

test("uses metadata thinking before off but keeps explicit thinking", async () => {
const cases = [
{
workspaceId: "ws-thinking-metadata",
override: null,
expected: "high:ws-thinking-metadata",
},
{
workspaceId: "ws-thinking-explicit",
override: "off" as const,
expected: "off:ws-thinking-explicit",
},
];

for (const testCase of cases) {
const metadata = createWorkspaceMetadata({
id: testCase.workspaceId,
aiSettings: { model: "openai:gpt-5.5", thinkingLevel: "high" },
});
const view = renderWithWorkspaceMetadata({
workspaceId: testCase.workspaceId,
metadata,
thinkingOverride: testCase.override,
children: (
<ThinkingProvider workspaceId={testCase.workspaceId}>
<TestComponent workspaceId={testCase.workspaceId} />
</ThinkingProvider>
),
});

await waitFor(() => {
expect(view.getByTestId("thinking").textContent).toBe(testCase.expected);
});
cleanup();
}
});

test("switching models does not remount children", async () => {
const workspaceId = "ws-1";

Expand Down
26 changes: 18 additions & 8 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils
import { useAPI } from "@/browser/contexts/API";
import {
clearPendingWorkspaceAiSettings,
getWorkspaceAiSettingsFromMetadata,
markPendingWorkspaceAiSettings,
} from "@/browser/utils/workspaceAiSettingsSync";
import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";

Expand Down Expand Up @@ -50,21 +52,29 @@ function getCanonicalModelForScope(scopeId: string, fallbackModel: string): stri

export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
const { api } = useAPI();
const workspaceContext = useOptionalWorkspaceContext();
const defaultModel = getDefaultModel();
const scopeId = getScopeId(props.workspaceId, props.projectPath);
const thinkingKey = getThinkingLevelKey(scopeId);

// Workspace-scoped thinking. (No longer per-model.)
const [thinkingLevel, setThinkingLevelInternal] = usePersistedState<ThinkingLevel>(
thinkingKey,
THINKING_LEVEL_OFF,
{ listener: true }
const metadataAgentId = readPersistedState<string>(
getAgentIdKey(scopeId),
WORKSPACE_DEFAULTS.agentId
);
const metadataSettings = getWorkspaceAiSettingsFromMetadata(
props.workspaceId ? workspaceContext?.workspaceMetadata.get(props.workspaceId) : undefined,
metadataAgentId
);

// Workspace-scoped thinking. Null means no explicit user choice has been persisted yet.
const [persistedThinkingLevel, setThinkingLevelInternal] =
usePersistedState<ThinkingLevel | null>(thinkingKey, null, { listener: true });
const thinkingLevel =
persistedThinkingLevel ?? metadataSettings.thinkingLevel ?? THINKING_LEVEL_OFF;
Comment thread
ibetitsmike marked this conversation as resolved.

// One-time migration: if the new workspace-scoped key is missing, seed from the legacy per-model key.
useEffect(() => {
const existing = readPersistedState<ThinkingLevel | undefined>(thinkingKey, undefined);
if (existing !== undefined) {
const existing = readPersistedState<ThinkingLevel | null | undefined>(thinkingKey, undefined);
if (existing != null) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const Tasks: Story = {

await canvas.findAllByText(/^Plan$/i);
await canvas.findAllByText(/^Exec$/i);
await canvas.findByRole("group", { name: "Exec defaults" });
await canvas.findAllByText(/^Explore$/i);
await canvas.findAllByText(/^Compact$/i);

Expand Down
Loading
Loading