Skip to content
Merged
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
28 changes: 28 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5903,6 +5903,34 @@ describe("createAgentChatService", () => {
db.close();
});

it("keeps CTO full access when a model switch crosses providers", async () => {
vi.mocked(mapPermissionToCodex).mockImplementation((mode) => {
if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" };
return { approvalPolicy: "on-request", sandbox: "read-only" };
});
const { db, ctoStateService, ctoMemoryService } = await createCtoServices();
try {
const { service } = createService({ ctoStateService, ctoMemoryService });
const session = await service.ensureIdentitySession({
identityKey: "cto",
laneId: "lane-1",
});

const updated = await service.updateSession({
sessionId: session.id,
modelId: "openai/gpt-5.5",
});

expect(updated.provider).toBe("codex");
expect(updated.permissionMode).toBe("full-auto");
expect(updated.codexApprovalPolicy).toBe("never");
expect(updated.codexSandbox).toBe("danger-full-access");
expect(ctoStateService.getIdentity().modelPreferences.modelId).toBe("openai/gpt-5.5");
} finally {
db.close();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("injects durable memory into the CTO reconstruction context", async () => {
const { db, ctoStateService, ctoMemoryService } = await createCtoServices();
ctoMemoryService.appendMemoryFact("The build long-pole is the Windows runner.");
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/services/cto/ctoState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ describe("ctoStateService", () => {
expect(preview.sections[4]?.content).toContain("spawnChat");
expect(preview.sections[4]?.content).toContain("createTerminal");
expect(preview.sections[4]?.content).toContain("Model Selection");
expect(preview.sections[4]?.content).toContain("ade actions run <domain.action>");
expect(preview.sections[4]?.content).toContain("bundled `ade-*` skills");
// Capabilities section: organized tool reference with descriptions
expect(preview.sections[5]?.content).toContain("ADE Operator Tools");
expect(preview.sections[5]?.content).toContain("listLanes");
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/services/cto/ctoStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ function buildCtoEnvironmentKnowledge(): string {
"Tool calling convention:",
" - Use the `ade` CLI per the ADE CLI operating guidance in your doctrine.",
" - If a tool from the manifest below is not in your immediate tool list, use the closest ADE CLI command or report the missing capability clearly.",
" - Every ADE service action exposed to agents is manageable from the CLI. Prefer a typed `ade <domain> ...` command, then use the runtime-generated `ade actions list --text` catalog and `ade actions run <domain.action>` for exact read, view, edit, and control coverage.",
" - Never rely on a memorized action inventory: the live action catalog and bundled `ade-*` skills are the source of truth for the installed ADE version.",
"",
"## PR Lifecycle in ADE",
"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,38 @@ describe("AgentChatComposer", () => {
expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull();
});

it("hides model, reasoning, and fast controls when the host surface owns them", () => {
renderComposer({
sessionProvider: "codex",
modelId: "openai/gpt-5.5",
availableModelIds: ["openai/gpt-5.5"],
fastMode: true,
hideModelControls: true,
});

expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull();
expect(screen.queryByRole("button", { name: "Reasoning effort" })).toBeNull();
expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull();
});

it("hides parallel slot model, reasoning, and fast controls when the host surface owns them", () => {
renderComposer({
sessionProvider: "codex",
availableModelIds: ["openai/gpt-5.5", "anthropic/claude-sonnet-5"],
hideModelControls: true,
parallelChatMode: true,
parallelConfiguringIndex: 0,
parallelModelSlots: [
{ modelId: "openai/gpt-5.5", reasoningEffort: "high", fastMode: true },
{ modelId: "anthropic/claude-sonnet-5", reasoningEffort: "medium" },
],
});

expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull();
expect(screen.queryByRole("button", { name: "Reasoning effort" })).toBeNull();
expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull();
});

it("renders Droid autonomy controls without OpenCode permission labels", () => {
const onDroidPermissionModeChange = vi.fn();
renderComposer({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ export function AgentChatComposer({
onModelPickerOpenRequestHandled,
permissionModeLocked = false,
hideNativeControls = false,
hideModelControls = false,
orchestrationRole = null,
messagePlaceholder,
inputLockMessage,
Expand Down Expand Up @@ -1310,6 +1311,8 @@ export function AgentChatComposer({
onModelPickerOpenRequestHandled?: () => void;
permissionModeLocked?: boolean;
hideNativeControls?: boolean;
/** Hide model, reasoning, and fast-mode controls when the host surface owns them. */
hideModelControls?: boolean;
/**
* Orchestration role lock (see `goal.md` §10.10).
* - `"lead"`: hide permission picker AND model picker once the lead
Expand Down Expand Up @@ -3992,7 +3995,7 @@ export function AgentChatComposer({
})}
</div>
) : null}
{parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? (
{!hideModelControls && parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? (
<>
<ModelPicker
value={parallelModelSlots[parallelConfiguringIndex]!.modelId}
Expand Down Expand Up @@ -4024,7 +4027,7 @@ export function AgentChatComposer({
/>
</>
) : null}
{!parallelChatMode && (orchestrationRole !== "lead" || !sessionId) ? (
{!hideModelControls && !parallelChatMode && (orchestrationRole !== "lead" || !sessionId) ? (
<>
<ModelPicker
value={modelId}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2779,6 +2779,7 @@ export function AgentChatPane({
lockSessionId,
hideSessionTabs = false,
hideNativeControls = false,
hideModelControls = false,
hideWorkspaceChrome = false,
hideSurfaceHeader = false,
hideLaneToolDrawers = false,
Expand Down Expand Up @@ -2824,6 +2825,8 @@ export function AgentChatPane({
lockSessionId?: string | null;
hideSessionTabs?: boolean;
hideNativeControls?: boolean;
/** Hide model/reasoning/fast controls when the embedding surface owns them. */
hideModelControls?: boolean;
hideWorkspaceChrome?: boolean;
/** Suppress the WorkSurfaceHeader row entirely (the host surface renders its own header, e.g. the CTO page). */
hideSurfaceHeader?: boolean;
Expand Down Expand Up @@ -10258,6 +10261,7 @@ export function AgentChatPane({
modelSelectionLocked={modelSelectionLocked || sessionMutationKind === "model" || turnActive || projectTransitionBlocksChat}
permissionModeLocked={permissionModeLocked || identitySessionSettingsBusy || projectTransitionBlocksChat}
hideNativeControls={hideNativeControls}
hideModelControls={hideModelControls}
messagePlaceholder={effectiveMessagePlaceholder}
inputLockMessage={subagentView
? `Viewing ${subagentMetadata?.label
Expand Down
57 changes: 30 additions & 27 deletions apps/desktop/src/renderer/components/cto/CtoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ import type {
import { AgentChatPane } from "../chat/AgentChatPane";
import { useAppStore } from "../../state/appStore";
import { cn } from "../ui/cn";
import { ModelPicker } from "../shared/ModelPicker/ModelPicker";
import { CtoSettingsPanel } from "./CtoSettingsPanel";
import { CtoOnboardingCard } from "./CtoOnboardingCard";
import { getCtoPersonalityPreset } from "./identityPresets";
import { getPersonalityTheme } from "./personalityTheme";
import { resolveModelSelection, useCtoModelOptions } from "./useCtoModelOptions";
import { resolveCtoPrimaryLaneId } from "./ctoSessionViewState";
Expand Down Expand Up @@ -56,14 +54,14 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
const ctoDisplayName = ctoIdentity?.name?.trim() || "CTO";
const personality = ctoIdentity?.personality ?? "strategic";
const theme = getPersonalityTheme(personality);
const personalityLabel = getCtoPersonalityPreset(personality).label;

const currentModelId = session?.modelId
?? ctoIdentity?.modelPreferences.modelId
?? "";
const currentReasoningEffort = session?.reasoningEffort
?? ctoIdentity?.modelPreferences.reasoningEffort
?? null;
const currentFastMode = session?.fastMode === true;

/* ── Data loading ── */

Expand Down Expand Up @@ -159,10 +157,10 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
await refreshSession();
}, [refreshSession]);

// One switch path for both the header badge and the Settings model row. With a
// live session it moves the running thread via updateSession (the composer's
// path, which also persists the choice into identity prefs). Before the session
// exists it writes identity prefs so ensureSession reconciles the model in.
// Settings owns model selection for the CTO. With a live session it moves the
// running thread via updateSession, which also persists the choice into
// identity prefs. Before the session exists it writes identity prefs so
// ensureSession reconciles the model in.
const handleModelChange = useCallback(async (modelId: string, reasoningEffort: string | null) => {
if (!window.ade?.cto || switchingModel) return;
const selection = resolveModelSelection(modelId, reasoningEffort);
Expand All @@ -175,6 +173,7 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
sessionId: session.id,
modelId: selection.modelId,
reasoningEffort: selection.reasoningEffort,
fastMode: selection.supportsFastMode && currentFastMode,
});
ctoPrimarySession = updated;
setSession(updated);
Expand All @@ -198,6 +197,26 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
} finally {
setSwitchingModel(false);
}
}, [currentFastMode, refreshSession, session, switchingModel]);

const handleFastModeChange = useCallback(async (enabled: boolean) => {
if (!window.ade?.cto || switchingModel) return;
setSwitchingModel(true);
setError(null);
try {
const targetSession = session ?? await refreshSession();
if (!targetSession) throw new Error("The CTO chat is still waking up.");
const updated = await window.ade.agentChat.updateSession({
sessionId: targetSession.id,
fastMode: enabled,
});
ctoPrimarySession = updated;
setSession(updated);
} catch (err) {
setError(err instanceof Error ? err.message : "Couldn't update Fast mode.");
} finally {
setSwitchingModel(false);
}
}, [refreshSession, session, switchingModel]);

const handleOnboardingComplete = useCallback(async () => {
Expand Down Expand Up @@ -242,6 +261,7 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
title: null,
goal: null,
reasoningEffort: session.reasoningEffort ?? null,
fastMode: session.fastMode === true,
executionMode: session.executionMode ?? null,
identityKey: session.identityKey,
capabilityMode: session.capabilityMode,
Expand Down Expand Up @@ -300,29 +320,9 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
{avatarInitial}
</div>
<span className="truncate text-[13px] font-semibold text-fg">{ctoDisplayName}</span>
<span
className="hidden shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium sm:inline"
style={{ background: `rgba(${theme.rgb}, 0.12)`, color: theme.hex }}
>
{personalityLabel}
</span>
</div>

<div className="ml-auto flex shrink-0 items-center gap-2">
{currentModelId ? (
<ModelPicker
value={currentModelId}
availableModelIds={availableModelIds}
surfaceKey="cto-header"
compact
disabled={switchingModel}
onChange={(modelId) => {
const selection = resolveModelSelection(modelId, currentReasoningEffort);
void handleModelChange(modelId, selection?.reasoningEffort ?? null);
}}
onOpenSignIn={openProviderSettings}
/>
) : null}
<button
type="button"
onClick={() => setSettingsOpen(true)}
Expand Down Expand Up @@ -350,6 +350,7 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
initialSessionSummary={lockedSessionSummary}
hideSessionTabs
hideNativeControls
hideModelControls
hideWorkspaceChrome
hideSurfaceHeader
presentation={presentation}
Expand Down Expand Up @@ -396,11 +397,13 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) {
sessionLogs={sessionLogs}
currentModelId={currentModelId}
currentReasoningEffort={currentReasoningEffort}
currentFastMode={currentFastMode}
availableModelIds={availableModelIds}
loadingModels={loadingModels}
switchingModel={switchingModel}
onSaveIdentity={handleSaveIdentity}
onModelChange={(modelId, reasoningEffort) => void handleModelChange(modelId, reasoningEffort)}
onFastModeChange={(enabled) => void handleFastModeChange(enabled)}
onOpenProviderSettings={openProviderSettings}
onResetOnboarding={handleResetOnboarding}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,27 @@ export function CtoSettingsPanel({
sessionLogs,
currentModelId,
currentReasoningEffort,
currentFastMode,
availableModelIds,
loadingModels,
switchingModel,
onSaveIdentity,
onModelChange,
onFastModeChange,
onOpenProviderSettings,
onResetOnboarding,
}: {
identity: CtoIdentity | null;
sessionLogs: CtoSessionLogEntry[];
currentModelId: string;
currentReasoningEffort: string | null;
currentFastMode: boolean;
availableModelIds: string[];
loadingModels: boolean;
switchingModel: boolean;
onSaveIdentity: (patch: Record<string, unknown>) => Promise<void>;
onModelChange: (modelId: string, reasoningEffort: string | null) => void;
onFastModeChange: (enabled: boolean) => void;
onOpenProviderSettings: () => void;
onResetOnboarding?: () => void;
}) {
Expand All @@ -73,6 +77,8 @@ export function CtoSettingsPanel({
availableModelIds={availableModelIds}
surfaceKey="cto-settings"
disabled={switchingModel}
fastModeActive={currentFastMode}
onFastModeToggle={onFastModeChange}
onChange={(modelId) => {
const selection = resolveModelSelection(modelId, currentReasoningEffort);
onModelChange(modelId, selection?.reasoningEffort ?? null);
Expand Down
Loading