Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9780b14
chat: stop ADE overriding the user's Claude settings at flag tier
arul28 Aug 20, 2026
6478597
codex: stop forcing service tier to "default" over the user's config.…
arul28 Aug 20, 2026
7051bc3
providers: read config from the directory each CLI actually uses
arul28 Aug 20, 2026
6ca21e8
cursor: stop switching off a sandbox policy the user configured
arul28 Aug 20, 2026
3ec6093
droid: inherit autonomy from the user's settings, and fix the dead mo…
arul28 Aug 20, 2026
e82c8c9
codex: keep reasoning effort on the thread, not on the app-server pro…
arul28 Aug 20, 2026
5138ce5
opencode: close the plan-mode write hole and stop overriding user config
arul28 Aug 20, 2026
ec92c85
Merge remote-tracking branch 'origin/main' into ade/claude-settings-p…
arul28 Aug 20, 2026
7659e28
quality: fix the passthrough that was undone downstream, plus review …
arul28 Aug 20, 2026
edf9875
quality: re-review pass — Windows path comparison and structural clea…
arul28 Aug 20, 2026
0521fa5
opencode: pin the isolated-lead autoupdate suppression with a test
arul28 Aug 20, 2026
e053c6b
droid: restore the only exit from Spec mode, and key one more path co…
arul28 Aug 20, 2026
f0f8c4a
test: pin the Droid settings contract, and align plan mode across bot…
arul28 Aug 20, 2026
4d095c6
test: make the Droid interaction-mode mapping testable and pin it
arul28 Aug 20, 2026
dc04bb5
docs+tui: record the provider config-ownership rule and honour CLAUDE…
arul28 Aug 20, 2026
07b88b6
review: fix four findings from Codex, Cursor Bugbot and CodeRabbit
arul28 Aug 20, 2026
54cc54b
droid: read the resumed session's mode instead of assuming it
arul28 Aug 20, 2026
1b72bfb
pty: honour CLAUDE_CONFIG_DIR for Claude session storage
arul28 Aug 20, 2026
98ad54c
review: don't infer Spec ownership, and don't invent an endpoint over…
arul28 Aug 20, 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
24 changes: 24 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
claudeHomePath,
defaultKeybindingsPath,
dispatchKeybinding,
keybindingsEditorCommand,
keypressToChord,
Expand Down Expand Up @@ -164,3 +168,23 @@ describe("keybindings", () => {
});
});
});

describe("claudeHomePath", () => {
it("follows CLAUDE_CONFIG_DIR, which is the directory the claude binary reads", () => {
const previous = process.env.CLAUDE_CONFIG_DIR;
try {
delete process.env.CLAUDE_CONFIG_DIR;
expect(claudeHomePath("settings.json")).toBe(path.join(os.homedir(), ".claude", "settings.json"));

process.env.CLAUDE_CONFIG_DIR = path.join(os.tmpdir(), "ade-claude-config-dir");
// The env var names the config directory itself — no ".claude" is appended.
expect(defaultKeybindingsPath()).toBe(
path.join(os.tmpdir(), "ade-claude-config-dir", "keybindings.json"),
);
expect(claudeHomePath("agents")).toBe(path.join(os.tmpdir(), "ade-claude-config-dir", "agents"));
} finally {
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = previous;
}
});
});
6 changes: 5 additions & 1 deletion apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1719,7 +1719,11 @@ export function isNewChatSetupPane(pane: RightPaneContent): boolean {

function formatOutputStyles(styles: Awaited<ReturnType<typeof listClaudeOutputStyles>>, activeStyle?: string | null): string {
if (!styles.length) return "No Claude output styles were found.";
const activeKey = activeStyle?.trim().toLowerCase() ?? "";
// A session carries no output style until a settings file names one — ADE no
// longer substitutes a value into the SDK options. For the *listing* only, an
// unset selection still reads as Claude's own "Default", which is what the
// desktop's `/output-style` handler shows. Display, never written back.
const activeKey = (activeStyle?.trim() || "Default").toLowerCase();
return [
"Claude output styles:",
"",
Expand Down
12 changes: 11 additions & 1 deletion apps/ade-cli/src/tuiClient/keybindings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { claudeConfigHome } from "../../../../desktop/src/main/services/shared/providerConfigHomes";

export const CLAUDE_KEYBINDING_CONTEXTS = [
"Global",
Expand Down Expand Up @@ -232,8 +233,17 @@ const DEFAULT_CONFIG = {
bindings: [],
};

/**
* The user-level Claude config directory the `claude` binary itself reads.
*
* `CLAUDE_CONFIG_DIR` names that directory outright, so hardcoding `~/.claude`
* makes the TUI read keybindings, statusLine, vim mode, and agents from a
* directory Claude Code is not using. `os.homedir()` is passed explicitly
* because the shared helper imports `homedir` by name, which a `vi.spyOn(os,
* "homedir")` in the suites below would otherwise miss.
*/
export function claudeHomePath(...segments: string[]): string {
return path.join(os.homedir(), ".claude", ...segments);
return path.join(claudeConfigHome({ homeDir: os.homedir() }), ...segments);
}

export function defaultKeybindingsPath(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,9 @@ describe("runProviderTask", () => {
const createOptions = cursorAgentCreateMock.mock.calls[0]![0] as Record<string, any>;
expect(createOptions.mode).toBe("agent");
expect(createOptions.local.autoReview).toBe(true);
expect(createOptions.local.sandboxOptions).toEqual({ enabled: false });
// Middle-trust maps to Cursor "agent", where ADE has no sandbox opinion: an
// explicit false would make the SDK skip the user's ~/.cursor/sandbox.json.
expect(createOptions.local.sandboxOptions).toBeUndefined();
expect(createOptions.tools).toBeUndefined();
});

Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/main/services/ai/providerTaskRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,11 @@ async function runCursorTask(args: ProviderTaskRunnerArgs): Promise<ProviderTask
...(local.disallowedTools ? { disallowedTools: local.disallowedTools } : {}),
local: {
cwd: args.cwd,
sandboxOptions: { enabled: local.sandboxEnabled },
// See CursorSdkSandboxDirective: an explicit `false` makes the SDK skip
// the user's ~/.cursor/sandbox.json, so absence is not the same as off.
...(local.sandboxDirective === "inherit"
? {}
: { sandboxOptions: { enabled: local.sandboxDirective === "enable" } }),
autoReview: local.autoReview,
},
};
Expand Down
176 changes: 161 additions & 15 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3508,8 +3508,13 @@ describe("createAgentChatService", () => {
]));
expect(opts?.includeHookEvents).toBe(true);
expect(opts?.promptSuggestions).toBe(true);
// No settings file names a style here, so ADE must not name one either:
// its settings land at flag tier, above every file the SDK reads, so an
// "outputStyle" key would override the user's global selection.
expect(opts?.settings).not.toHaveProperty("outputStyle");
expect(opts?.settings).toEqual(expect.objectContaining({
outputStyle: "Default",
// ADE's own default, which applies only while no settings file states one.
workflowSizeGuideline: "medium",
fastMode: false,
enabledPlugins: expect.objectContaining({
"learning-output-style@claude-code-plugins": false,
Expand All @@ -3520,6 +3525,52 @@ describe("createAgentChatService", () => {
}));
});

it("passes the user's global output style through instead of pinning Default", async () => {
// The regression this guards: ADE substituted "Default" for "nothing is
// set" and passed it at flag tier, so a style configured in the user's
// settings.json never took effect in any ADE chat.
const userClaudeDir = path.join(tmpRoot, "user-claude-config");
fs.mkdirSync(path.join(userClaudeDir, "output-styles"), { recursive: true });
fs.writeFileSync(
path.join(userClaudeDir, "output-styles", "asd-ste100.md"),
["---", "name: ASD-STE100", "description: Simplified Technical English", "---", "", "Write short sentences.", ""].join("\n"),
);
fs.writeFileSync(
path.join(userClaudeDir, "settings.json"),
JSON.stringify({ outputStyle: "ASD-STE100", workflowSizeGuideline: "large" }),
);
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = userClaudeDir;

try {
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send: vi.fn(),
stream: vi.fn(async function* () {
return;
}),
close: vi.fn(),
sessionId: "sdk-session-user-output-style",
} as any);

const { service } = createService();
await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });

await vi.waitFor(() => {
expect(claudeSdkCreateSessionCompat).toHaveBeenCalled();
});

const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as {
settings?: { outputStyle?: string; workflowSizeGuideline?: string };
} | undefined;
expect(opts?.settings?.outputStyle).toBe("ASD-STE100");
// A user-stated guideline replaces ADE's default rather than losing to it.
expect(opts?.settings).not.toHaveProperty("workflowSizeGuideline");
} finally {
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
});

it("passes Claude fast mode through SDK flag settings for Opus sessions", async () => {
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send: vi.fn(),
Expand Down Expand Up @@ -5937,6 +5988,76 @@ describe("createAgentChatService", () => {
})).rejects.toThrow("transcript history without fork mode");
});

it("derives Droid autonomy from ADE's own permission chip", async () => {
// ADE always carries a generic permissionMode, and it maps onto a Droid
// mode — so ADE does state autonomy here, deliberately. This is the
// ADE-owned half of the rule: there IS a control for it, so ADE's value
// wins. The omission path exists for launches that carry no permission
// mode at all (programmatic/mobile), not for this one.
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "droid",
model: "claude-opus-4-6",
modelId: "droid/claude-opus-4-6",
});
await service.sendMessage({ sessionId: session.id, text: "hello" });

await vi.waitFor(() => {
expect(mockState.droidAcquireCalls.length).toBeGreaterThan(0);
});
const settings = mockState.droidAcquireCalls[0]?.settings as Record<string, unknown>;
expect(settings.autonomyLevel).toBe("low");
expect(settings.interactionMode).toBe("auto");
// Never null: an explicit null wedges the Droid RPC for 30 seconds.
expect(settings.autonomyLevel).not.toBeNull();
expect(settings.interactionMode).not.toBeNull();
});

it("states the Droid autonomy the user picked explicitly", async () => {
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "droid",
model: "claude-opus-4-6",
modelId: "droid/claude-opus-4-6",
droidPermissionMode: "auto-high",
});
await service.sendMessage({ sessionId: session.id, text: "hello" });

await vi.waitFor(() => {
expect(mockState.droidAcquireCalls.length).toBeGreaterThan(0);
});
const settings = mockState.droidAcquireCalls[0]?.settings as Record<string, unknown>;
expect(settings.autonomyLevel).toBe("high");
expect(settings.interactionMode).toBe("auto");
expect(settings).not.toHaveProperty("specModeModelId");
});

it("maps a Droid plan session onto spec mode with autonomy off", async () => {
// Plan must stay read-only. Spec dominates Droid's compound autonomyMode,
// and the spec-mode model fields have to ride along with it — they are
// gated on the stated interaction mode, so they cannot be emitted for a
// session that stated none.
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "droid",
model: "claude-opus-4-6",
modelId: "droid/claude-opus-4-6",
interactionMode: "plan",
});
await service.sendMessage({ sessionId: session.id, text: "plan this" });

await vi.waitFor(() => {
expect(mockState.droidAcquireCalls.length).toBeGreaterThan(0);
});
const settings = mockState.droidAcquireCalls[0]?.settings as Record<string, unknown>;
expect(settings.interactionMode).toBe("spec");
expect(settings.autonomyLevel).toBe("off");
expect(settings.specModeModelId).toBeTruthy();
});

it("refuses a cross-machine Droid fork with the portability message", async () => {
installCleanCrossMachineGitFixture();
const { service } = createService();
Expand Down Expand Up @@ -7690,7 +7811,7 @@ describe("createAgentChatService", () => {
}
});

it("passes the selected Codex reasoning effort into app-server config", async () => {
it("passes the selected Codex reasoning effort per thread, not on the process", async () => {
const laneRootPath = path.join(tmpRoot, "lane-2");
fs.mkdirSync(laneRootPath, { recursive: true });

Expand All @@ -7711,11 +7832,13 @@ describe("createAgentChatService", () => {
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true);
});

expect(spawn).toHaveBeenCalledWith(
"codex",
["app-server", "-c", "model_reasoning_effort=\"low\""],
expect.any(Object),
);
// Reasoning effort is a per-chat choice, so it must ride the thread and
// never the process: `-c` is the highest config layer (above the user's
// ~/.codex/config.toml and their per-project .codex/config.toml) and a
// spawn arg would apply to every thread on this app-server.
expect(spawn).toHaveBeenCalledWith("codex", ["app-server"], expect.any(Object));
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1] as string[] | undefined;
expect(spawnArgs?.join(" ")).not.toContain("model_reasoning_effort");

const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start");
const startParams = startPayload?.params as {
Expand Down Expand Up @@ -7810,7 +7933,7 @@ describe("createAgentChatService", () => {
expect(getAdeCliAgentEnv).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
"codex",
["app-server", "-c", "model_reasoning_effort=\"medium\""],
["app-server"],
expect.objectContaining({
env: expect.objectContaining({
PATH: "/tmp/ade-cli/bin",
Expand Down Expand Up @@ -20222,7 +20345,7 @@ describe("createAgentChatService", () => {

expect(spawn).toHaveBeenCalledWith(
"codex",
["app-server", "-c", "model_reasoning_effort=\"medium\""],
["app-server"],
expect.objectContaining({ detached: process.platform !== "win32" }),
);
expect(processKillSpy).toHaveBeenCalledWith(-99999, "SIGTERM");
Expand Down Expand Up @@ -28265,7 +28388,7 @@ describe("createAgentChatService", () => {
}));
});

it("explicitly clears Codex service tier when fast mode is off", async () => {
it("omits Codex service tier when fast mode was never turned on", async () => {
mockState.codexResponseOverrides.set("thread/start", (payload) => ({
thread: { id: "thread-default" },
serviceTier: (payload.params as { serviceTier?: unknown } | undefined)?.serviceTier ?? null,
Expand All @@ -28286,17 +28409,20 @@ describe("createAgentChatService", () => {
expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
});

// Verified against a live app-server: omitting inherits the user's
// config.toml service_tier, while an explicit null forces "default".
// ADE has no service-tier UI, so it must not name the key at all.
const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start");
expect((threadStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull();
expect(threadStartRequest?.params).not.toHaveProperty("serviceTier");
const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull();
expect(turnStartRequest?.params).not.toHaveProperty("serviceTier");
const summary = await service.getSessionSummary(session.id);
expect(summary?.fastMode).toBe(false);
expect(summary?.codexServiceTier).toBeNull();
expect(readPersistedChatState(session.id).codexServiceTier).toBeNull();
});

it("preserves fast mode selection on unsupported Codex models while sending standard tier", async () => {
it("preserves fast mode selection on unsupported Codex models without naming a tier", async () => {
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
Expand All @@ -28315,9 +28441,9 @@ describe("createAgentChatService", () => {
});

const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start");
expect((threadStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull();
expect(threadStartRequest?.params).not.toHaveProperty("serviceTier");
const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull();
expect(turnStartRequest?.params).not.toHaveProperty("serviceTier");
expect((await service.getSessionSummary(session.id))?.fastMode).toBe(true);
});

Expand Down Expand Up @@ -42249,3 +42375,23 @@ describe("host sleep narration", () => {
}
});
});

describe("claude output style listing", () => {
it("does not persist an output style just because the list was shown", async () => {
// Listing styles used to write the resolved name onto the session and
// persist it. Every later option build then treated that cache as a real
// selection, so ADE sent outputStyle at flag tier and suppressed Claude's
// own resolution — the override this branch exists to stop.
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "claude-sonnet-5",
});

await service.sendMessage({ sessionId: session.id, text: "/output-style" });

expect(readPersistedChatState(session.id).claudeOutputStyle ?? null).toBeNull();
expect((await service.getSessionSummary(session.id))?.claudeOutputStyle ?? null).toBeNull();
});
});
Loading
Loading