Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 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
295 changes: 292 additions & 3 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@ 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 type { RecursivePartial } from "@/browser/testUtils";

let currentClientMock: RecursivePartial<APIClient> = {};
let metadataMap = new Map<string, FrontendWorkspaceMetadata>();
import {
getModelKey,
getProjectScopeId,
getThinkingLevelByModelKey,
getThinkingLevelKey,
} from "@/common/constants/storage";
import type { RecursivePartial } from "@/browser/testUtils";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";

let currentClientMock: RecursivePartial<APIClient> = {};

// Setup basic DOM environment for testing-library
const dom = new GlobalWindow();
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
Expand All @@ -34,6 +42,13 @@ interface TestProps {
workspaceId: string;
}

type WorkspaceUpdateAgentAISettingsArgs = Parameters<
APIClient["workspace"]["updateAgentAISettings"]
>[0];
type WorkspaceUpdateAgentAISettingsResult = Awaited<
ReturnType<APIClient["workspace"]["updateAgentAISettings"]>
>;

const TestComponent: React.FC<TestProps> = (props) => {
const [thinkingLevel] = useThinkingLevel();
return (
Expand All @@ -43,10 +58,161 @@ 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 ThinkingSetterComponent: React.FC = () => {
const [, setThinkingLevel] = useThinkingLevel();
return (
<button data-testid="set-thinking-medium" onClick={() => setThinkingLevel("medium")}>
Set thinking
</button>
);
};

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 setWorkspaceMetadata(metadata: FrontendWorkspaceMetadata) {
metadataMap = new Map([[metadata.id, metadata]]);
}

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 !== undefined) {
if (props.thinkingOverride == null) {
window.localStorage.removeItem(getThinkingLevelKey(props.workspaceId));
} else {
updatePersistedState(getThinkingLevelKey(props.workspaceId), props.thinkingOverride);
}
}

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

function createWorkspaceClient(): APIClient {
const workspaceOverrides = currentClientMock.workspace ?? {};
const projectOverrides = currentClientMock.projects ?? {};
const serverOverrides = currentClientMock.server ?? {};

return {
...currentClientMock,
workspace: {
list: () => Promise.resolve(Array.from(metadataMap.values())),
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()),
...workspaceOverrides.activity,
},
truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }),
interruptStream: () => Promise.resolve({ success: true as const, data: undefined }),
...workspaceOverrides,
},
projects: {
list: () => Promise.resolve([]),
listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }),
secrets: {
get: () => Promise.resolve([]),
...projectOverrides.secrets,
},
...projectOverrides,
},
server: {
getLaunchProject: () => Promise.resolve(null),
...serverOverrides,
},
} as unknown as APIClient;
}

function renderWithWorkspaceMetadata(props: {
workspaceId: string;
modelOverride?: string | null;
thinkingOverride?: "off" | null;
children: React.ReactNode;
}) {
// Use the real WorkspaceProvider so this file does not poison other Bun test files
// by replacing the whole WorkspaceContext module globally.
return render(
<APIProvider client={createWorkspaceClient()}>
<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 @@ -61,15 +227,138 @@ describe("ThinkingContext", () => {
),
},
};
metadataMap = new Map();
window.localStorage.clear();
window.localStorage.setItem("model-default", JSON.stringify("openai:default"));
});

afterEach(() => {
cleanup();
metadataMap = new Map();
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" },
});
setWorkspaceMetadata(metadata);

const view = renderWithWorkspaceMetadata({
workspaceId: testCase.workspaceId,
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("setting thinking uses metadata model before global default", async () => {
const workspaceId = "ws-set-thinking-metadata-model";
const updateAgentAISettings = mock<
(args: WorkspaceUpdateAgentAISettingsArgs) => Promise<WorkspaceUpdateAgentAISettingsResult>
>(() =>
Promise.resolve({
success: true as const,
data: undefined,
})
);
currentClientMock = {
workspace: { updateAgentAISettings },
};

setWorkspaceMetadata(
createWorkspaceMetadata({
id: workspaceId,
aiSettings: { model: "metadataModel:abc", thinkingLevel: "high" },
})
);

const view = renderWithWorkspaceMetadata({
workspaceId,
modelOverride: null,
children: (
<ThinkingProvider workspaceId={workspaceId}>
<ThinkingSetterComponent />
</ThinkingProvider>
),
});

const button = await view.findByTestId("set-thinking-medium");
act(() => {
button.click();
});

await waitFor(() => {
expect(updateAgentAISettings).toHaveBeenCalledWith({
workspaceId,
agentId: "exec",
aiSettings: { model: "metadataModel:abc", thinkingLevel: "medium" },
});
});
});

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" },
});
setWorkspaceMetadata(metadata);

const view = renderWithWorkspaceMetadata({
workspaceId: testCase.workspaceId,
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
Loading
Loading