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
13 changes: 4 additions & 9 deletions apps/mesh/src/api/routes/decopilot/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { createHash } from "node:crypto";
import type { StudioContext } from "@/core/studio-context";
import {
TierUnavailableError,
resolveCliTier,
resolveTier,
tryResolveTier,
} from "@/core/resolve-tier";
import { resolveAgentTier } from "@/ai-providers/agent-tiers";
import type { ChatTier, SimpleModeTier } from "@/tools/organization/schema";
import { posthog } from "@/posthog";
import { consumeStream, createUIMessageStreamResponse } from "ai";
Expand Down Expand Up @@ -226,14 +226,9 @@ async function resolvePerRequestModels(
tier === "fast" || tier === "smart" || tier === "thinking"
? tier
: "smart";
const entry = resolveAgentTier(harnessId, chatTier);
if (!entry) {
// Should be unreachable — resolveAgentTier returns non-null for
// both supported CLI harnesses and every ChatTier value.
throw new Error(
`No model mapping for harness "${harnessId}" tier "${chatTier}"`,
);
}
// Prefer the org's `simple_mode.cli` override; falls back to the hardcoded
// default when unset.
const entry = await resolveCliTier(ctx, harnessId, chatTier);
return {
credentialId: `desktop:${harnessId}`,
thinking: {
Expand Down
99 changes: 98 additions & 1 deletion apps/mesh/src/core/resolve-tier.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it, mock } from "bun:test";
import type { StudioContext } from "@/core/studio-context";
import { resolveTier, TierUnavailableError } from "./resolve-tier";
import type { SimpleModeConfig } from "@/storage/types";
import {
resolveCliTier,
resolveTier,
TierUnavailableError,
} from "./resolve-tier";

interface ProviderKeyFixture {
id: string;
Expand Down Expand Up @@ -177,3 +182,95 @@ describe("resolveTier", () => {
expect(result.modelId).toBe("claude-sonnet-4-6");
});
});

function makeCliCtx(opts: {
simpleMode?: SimpleModeConfig | null;
orgId?: string | null;
getThrows?: boolean;
}): StudioContext {
return {
organization:
opts.orgId === null ? undefined : { id: opts.orgId ?? "org_1" },
storage: {
organizationSettings: {
get: mock(() =>
opts.getThrows
? Promise.reject(new Error("boom"))
: Promise.resolve(
opts.simpleMode ? { simple_mode: opts.simpleMode } : null,
),
),
},
},
} as unknown as StudioContext;
}

describe("resolveCliTier", () => {
it("returns the hardcoded default when no override is set", async () => {
const ctx = makeCliCtx({ simpleMode: null });
const entry = await resolveCliTier(ctx, "claude-code", "smart");
expect(entry.modelId).toBe("claude-code:sonnet");
expect(entry.label).toBe("Sonnet 5");
});

it("prefers the org override when present", async () => {
const ctx = makeCliCtx({
simpleMode: {
tiers: {
fast: null,
smart: null,
thinking: null,
image: null,
web_search: null,
deep_research: null,
},
cli: {
"claude-code": {
fast: null,
smart: { modelId: "claude-code:opus-1m", title: "Opus 4.8 1M" },
thinking: null,
},
},
},
});
const entry = await resolveCliTier(ctx, "claude-code", "smart");
expect(entry.modelId).toBe("claude-code:opus-1m");
expect(entry.label).toBe("Opus 4.8 1M");
});

it("falls back to default for a tier the override does not set", async () => {
const ctx = makeCliCtx({
simpleMode: {
tiers: {
fast: null,
smart: null,
thinking: null,
image: null,
web_search: null,
deep_research: null,
},
cli: {
codex: {
fast: { modelId: "codex:gpt-5.5", title: "GPT-5.5" },
smart: null,
thinking: null,
},
},
},
});
const entry = await resolveCliTier(ctx, "codex", "smart");
expect(entry.modelId).toBe("codex:gpt-5.4");
});

it("returns the default when there is no organization in context", async () => {
const ctx = makeCliCtx({ orgId: null });
const entry = await resolveCliTier(ctx, "codex", "fast");
expect(entry.modelId).toBe("codex:gpt-5.4-mini");
});

it("returns the default when the settings read fails", async () => {
const ctx = makeCliCtx({ getThrows: true });
const entry = await resolveCliTier(ctx, "claude-code", "thinking");
expect(entry.modelId).toBe("claude-code:opus-1m");
});
});
44 changes: 43 additions & 1 deletion apps/mesh/src/core/resolve-tier.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { StudioContext } from "@/core/studio-context";
import type { SimpleModeTier } from "@/tools/organization/schema";
import type { ChatTier, SimpleModeTier } from "@/tools/organization/schema";
import {
resolveAgentTier,
type AgentTierEntry,
} from "@/ai-providers/agent-tiers";
import type { CliHarnessId } from "@/storage/types";
import {
pickSimpleModeDefaults,
type AiProviderKey,
Expand Down Expand Up @@ -180,6 +185,43 @@ export async function resolveSpecificModel(
};
}

/**
* Resolve the model a local CLI harness (Claude Code / Codex) should run for a
* chat tier. Prefers the org's `simple_mode.cli` override, falling back to the
* hardcoded default in `resolveAgentTier`. Reads settings best-effort — a
* missing org, missing override, or a read failure all fall through to the
* default, so dispatch never fails on the CLI path.
*/
export async function resolveCliTier(
ctx: StudioContext,
harnessId: CliHarnessId,
tier: ChatTier,
): Promise<AgentTierEntry> {
const fallback = resolveAgentTier(harnessId, tier);
if (!fallback) {
// Unreachable — resolveAgentTier is total over CLI harness × ChatTier.
throw new Error(
`No model mapping for harness "${harnessId}" tier "${tier}"`,
);
}

const orgId = ctx.organization?.id;
if (!orgId) return fallback;

try {
const settings = await ctx.storage.organizationSettings.get(orgId);
const slot = settings?.simple_mode?.cli?.[harnessId]?.[tier] ?? null;
if (!slot) return fallback;
return { modelId: slot.modelId, label: slot.title ?? slot.modelId };
} catch (err) {
console.warn(
`[resolveCliTier] override lookup failed for "${harnessId}"/"${tier}":`,
err,
);
return fallback;
}
}

export async function tryResolveTier(
ctx: StudioContext,
tier: SimpleModeTier,
Expand Down
15 changes: 15 additions & 0 deletions apps/mesh/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,23 @@ export type SimpleModeTier =
| "web_search"
| "deep_research";

/** Keyless slot for local CLI harnesses — the credential is desktop-side. */
export interface CliModelSlot {
modelId: string;
title?: string;
}

export type CliHarnessId = "claude-code" | "codex";

export interface CliTierConfig {
fast: CliModelSlot | null;
smart: CliModelSlot | null;
thinking: CliModelSlot | null;
}

export interface SimpleModeConfig {
tiers: Record<SimpleModeTier, SimpleModeModelSlot | null>;
cli?: Partial<Record<CliHarnessId, CliTierConfig>>;
}

export interface DefaultHomeAgentsConfig {
Expand Down
34 changes: 34 additions & 0 deletions apps/mesh/src/tools/organization/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ export const ChatTierSchema = z.enum(["fast", "smart", "thinking"]);

export type ChatTier = z.infer<typeof ChatTierSchema>;

/**
* Keyless model slot for local CLI harnesses (Claude Code / Codex). Unlike the
* cloud `ModelSlotSchema`, there is no `keyId` — the credential lives on the
* user's desktop link, not in an `ai_provider_keys` row.
*/
const CliModelSlotSchema = z
.object({
modelId: z.string(),
title: z.string().optional(),
})
.nullable();

/** Per-harness fast/smart/thinking override for a local CLI runtime. */
export const CliTierConfigSchema = z.object({
fast: CliModelSlotSchema,
smart: CliModelSlotSchema,
thinking: CliModelSlotSchema,
});

export type CliTierConfig = z.infer<typeof CliTierConfigSchema>;

export const SimpleModeConfigSchema = z.object({
tiers: z.object({
fast: ModelSlotSchema,
Expand All @@ -74,6 +95,19 @@ export const SimpleModeConfigSchema = z.object({
web_search: ModelSlotSchema,
deep_research: ModelSlotSchema,
}),
/**
* Optional per-harness tier→model overrides for local CLI runtimes. Absent
* (or a missing harness/tier) falls back to the hardcoded default in
* `resolveAgentTier`. Lives in the same JSON blob as `tiers` so no migration
* is needed.
*/
cli: z
.object({
"claude-code": CliTierConfigSchema,
codex: CliTierConfigSchema,
})
.partial()
.optional(),
});

export type SimpleModeConfig = z.infer<typeof SimpleModeConfigSchema>;
Expand Down
15 changes: 11 additions & 4 deletions apps/mesh/src/web/components/chat/chat-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import {
agentOptionFor,
type AgentOption,
} from "./pills/agent-options";
import { useCurrentLink } from "../../hooks/use-current-link";
import { defaultAgentOption } from "../../lib/agent-capabilities";
import { resolveSubmitSettings } from "./resolve-submit-settings";
import {
isDeepResearchModel,
Expand Down Expand Up @@ -602,10 +604,15 @@ export function ChatPrefsProvider({ children }: PropsWithChildren) {
)
: null;

// Preserve the user's selected runtime exactly. Presence/capability probes are
// advisory only; dispatch should surface the real backend error if the choice
// cannot run.
const selectedAgentOption = pendingAgentOption;
// Preserve the user's explicit selection exactly. Presence/capability probes
// are advisory only; dispatch should surface the real backend error if the
// choice cannot run. When the user hasn't picked (`null`), resolve a smart
// default: with no cloud provider keys, a cloud Decopilot default can't run,
// so fall back to a linked local CLI harness (Claude Code / Codex) when one
// is available. SaaS (keys present) keeps the cloud default.
const link = useCurrentLink();
const selectedAgentOption =
pendingAgentOption ?? defaultAgentOption(keys.length > 0, link);

// When the thread is locked, the agent option is dictated by the persisted
// (harness, sandbox) pair — period. Otherwise, fall through to the user's
Expand Down
77 changes: 4 additions & 73 deletions apps/mesh/src/web/components/chat/pills/chat-mode-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,54 +13,20 @@ mock.module("../../thread/github/branch-picker", () => ({

import { ChatModeRowPure } from "./chat-mode-row";
import { BranchPill } from "./branch-pill";
import { ModePickerPure } from "./mode-picker";

describe("ChatModeRowPure", () => {
it("returns null when both pills are null", () => {
const { container } = render(
<ChatModeRowPure branchPill={null} modePicker={null} />,
);
it("returns null when branchPill is null", () => {
const { container } = render(<ChatModeRowPure branchPill={null} />);
expect(container.firstChild).toBeNull();
});

it("renders only the BranchPill when ModePicker is null", () => {
const { getByTestId, queryByTestId } = render(
<ChatModeRowPure
branchPill={<span data-testid="branch-pill">branch</span>}
modePicker={null}
/>,
);
expect(getByTestId("branch-pill")).toBeInTheDocument();
expect(queryByTestId("mode-picker")).toBeNull();
});

it("renders only the ModePicker when BranchPill is null", () => {
const { getByTestId, queryByTestId } = render(
<ChatModeRowPure
branchPill={null}
modePicker={<span data-testid="mode-picker">mode</span>}
/>,
);
expect(getByTestId("mode-picker")).toBeInTheDocument();
expect(queryByTestId("branch-pill")).toBeNull();
});

it("renders both pills, ModePicker before BranchPill", () => {
it("renders the BranchPill when provided", () => {
const { getByTestId } = render(
<ChatModeRowPure
branchPill={<span data-testid="branch-pill">branch</span>}
modePicker={<span data-testid="mode-picker">mode</span>}
/>,
);
const branch = getByTestId("branch-pill");
const mode = getByTestId("mode-picker");
expect(branch).toBeInTheDocument();
expect(mode).toBeInTheDocument();
// Harness (ModePicker) reads first so the user sees:
// "using [Cloud] on branch [main]" left-to-right.
expect(
mode.compareDocumentPosition(branch) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(getByTestId("branch-pill")).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -93,38 +59,3 @@ describe("BranchPill", () => {
expect(getByRole("button")).toBeInTheDocument();
});
});

describe("ChatModeRow integration", () => {
it("renders both pills as non-interactive when locked=true", () => {
const { getByTestId, queryAllByRole } = render(
<ChatModeRowPure
branchPill={
<BranchPill {...BRANCH_PILL_PROPS} locked={true} value="main" />
}
modePicker={
<ModePickerPure
mode="cloud-decopilot"
availability={{
agentSandbox: true,
userDesktop: false,
claudeCode: false,
codex: false,
}}
locked={true}
onSelect={mock(() => {})}
/>
}
/>,
);

// Both locked elements are present
expect(getByTestId("branch-picker-locked")).toBeInTheDocument();
expect(getByTestId("mode-picker-locked")).toBeInTheDocument();

// No interactive (enabled) buttons should be present
const buttons = queryAllByRole("button");
for (const btn of buttons) {
expect(btn).toBeDisabled();
}
});
});
Loading
Loading