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
3 changes: 2 additions & 1 deletion apps/ade-cli/src/services/agentRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CURSOR_CLI_EXECUTABLES } from "../../../desktop/src/shared/providerCliExecutables";
import { resolveProviderRemediation } from "../../../desktop/src/shared/providerRemediation";
import { COPILOT_NPM_PACKAGE_SPEC } from "../../../desktop/src/shared/acpProviderMetadata";
import type { ShippedProvider } from "../../../desktop/src/shared/providers";

export type AgentCliErrorCategory = "missing" | "unauthenticated";
Expand Down Expand Up @@ -230,7 +231,7 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [
agent: "copilot",
displayName: "GitHub Copilot CLI",
binaryNames: ["copilot"],
installCommand: npmGlobalInstallCommand("@github/copilot"),
installCommand: npmGlobalInstallCommand(COPILOT_NPM_PACKAGE_SPEC),
authCommand: "copilot login",
missingErrorPatterns: [
/\bcopilot\b.*\b(command not found|not recognized|not found|enoent)\b/i,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@
*
* ## Verified rules
*
* - Version 1.0.82 (ACP agent 1.0.4) advertises `loadSession`, image prompts,
* and session list. It does **not** advertise `session/resume` or
* `session/close`; both answer -32601. ADE still sends `session/close` and
* degrades, keeping the pooled process. Live 1.0.82 on this machine completed
* real `session/prompt` turns (text `"ping"`, usage on the prompt result and
* `usage_update`). Cancel mid-prompt returned `stopReason: "end_turn"` with
* partial text — github/copilot-cli #4561, live. Config options arrive as
* `currentValue` / nested `value`, not ADE's `value` / `options[].id`.
* - The 1.0.86 compatibility baseline advertises `loadSession`, image prompts,
* HTTP/SSE MCP, and session list/close. It does not advertise
* `session/resume`. Config options arrive as `currentValue` / nested `value`,
* not ADE's `value` / `options[].id`. Older 1.0.x binaries may omit close;
* the host gates lifecycle calls against the handshake and keeps a shared
* process alive when it has to degrade.
* - **Known bug.** Cancel may report `stopReason: "end_turn"`
* (github/copilot-cli issue 4561). ADE records its own cancel and marks the
* turn interrupted whatever the agent says. That accounting lives in the
Expand Down Expand Up @@ -60,6 +58,7 @@ import {
standardClose,
standardLoad,
standardSetModel,
standardSetConfigOption,
transportGatedMcpInjection,
withOptionalEnv,
} from "./shared";
Expand Down Expand Up @@ -89,6 +88,32 @@ export const COPILOT_TUI_ONLY_COMMANDS: ReadonlySet<string> = new Set([
export const COPILOT_CANCEL_DEGRADATION_NOTE =
"Copilot sometimes reports a stopped turn as finished. ADE marks it stopped.";

export const COPILOT_PERMISSION_DEGRADATION_NOTE =
"Copilot ACP has no auto-edit mode. ADE maps auto-edit and auto to approval-gated Agent mode.";

export function copilotPermissionModeDegradationNote(mode: string | null | undefined): string | null {
return mode === "auto-edit" || mode === "auto" ? COPILOT_PERMISSION_DEGRADATION_NOTE : null;
}

export const COPILOT_NATIVE_MODE_IDS = {
agent: "https://agentclientprotocol.com/protocol/session-modes#agent",
plan: "https://agentclientprotocol.com/protocol/session-modes#plan",
autopilot: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
} as const;

export const COPILOT_CONFIG_OPTION_IDS = ["mode", "allow_all"] as const;

export function copilotNativeModeValue(mode: string): string {
if (mode === "plan") return COPILOT_NATIVE_MODE_IDS.plan;
if (mode === "yolo") return COPILOT_NATIVE_MODE_IDS.autopilot;
return COPILOT_NATIVE_MODE_IDS.agent;
Comment thread
arul28 marked this conversation as resolved.
Comment thread
arul28 marked this conversation as resolved.
}

/** Copilot's Agent fallback remains approval-gated for ADE supervision. */
export function copilotSupervisionPermissionMode(mode: string | null | undefined): string | null | undefined {
return mode === "auto-edit" || mode === "auto" ? "default" : mode;
}

function normalizeCommandName(name: string): string {
return name.replace(/^\/+/, "").trim().toLowerCase();
}
Expand Down Expand Up @@ -124,9 +149,14 @@ export const copilotDialect = defineAcpDialect({
binaryNames: ["copilot"],
buildSpawnPlan,

// Copilot 1.0.82 answers a `session/cancel` REQUEST with -32601. The
// notification form is the one the binary accepts, same as Grok.
// Copilot 1.0.82 answered a `session/cancel` REQUEST with -32601. The
// notification form is the compatibility-safe path, same as Grok.
cancelStyle: "notification",
// `--model` and `--effort` are process global, so two chats with different
// values must not share a process. Those values are part of the pool key.
// `--model` is supported by ACP in the 1.0.86 baseline even though it is not
// a session config option.
//
// `--effort` is process global, so two chats with different effort values
// must not share a process. The environment carries the config home; the
// effort flag is folded into the pool key by the caller through the spawn
Expand Down Expand Up @@ -155,6 +185,7 @@ export const copilotDialect = defineAcpDialect({
},

degradationNotes: [COPILOT_CANCEL_DEGRADATION_NOTE],
degradationNoteForMode: copilotPermissionModeDegradationNote,

usageSource: "usage_update",
usage: capability(({ usageUpdate, promptUsage }) => {
Expand Down Expand Up @@ -189,9 +220,12 @@ export const copilotDialect = defineAcpDialect({
resumeSession: capabilityAbsent,
loadSession: capability(standardLoad),

sessionConfig: capabilityAbsent,
nativeModeValue: copilotNativeModeValue,
supervisionPermissionMode: copilotSupervisionPermissionMode,
modeSetupRequired: true,
sessionConfig: capability(standardSetConfigOption),
modelSelection: capability(standardSetModel),
mcpInjection: capability(transportGatedMcpInjection),
imagePrompts: capability(inlineImagePrompt),
configOptionIds: [],
configOptionIds: COPILOT_CONFIG_OPTION_IDS,
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ export function acpDialectFor(providerId: AcpProviderId): AcpDialect {
}

export { copilotDialect, grokDialect, kimiDialect, qwenDialect };
export { COPILOT_TUI_ONLY_COMMANDS, includeCopilotSlashCommand } from "./copilot";
export {
COPILOT_CONFIG_OPTION_IDS,
COPILOT_NATIVE_MODE_IDS,
COPILOT_TUI_ONLY_COMMANDS,
copilotPermissionModeDegradationNote,
copilotNativeModeValue,
copilotSupervisionPermissionMode,
includeCopilotSlashCommand,
} from "./copilot";
export {
GROK_CLAUDE_MARKER_OVERRIDE_ENV,
GROK_MINIMUM_VERSION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const qwenDialect = defineAcpDialect({
loadSession: capability(standardLoad),

sessionConfig: capability(standardSetConfigOption),
modeSetupRequired: true,
modelSelection: capabilityAbsent,
mcpInjection: capability(transportGatedMcpInjection),
imagePrompts: capability(inlineImagePrompt),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* Dialect claims vs captured initialize responses from real binaries.
*
* (ACP agent 1.0.4), Grok 1.0.13, and the Kimi Code 0.39.1 compatibility
* baseline. Kimi Code 2.0.0's current ACP reference is covered by the dialect
* contract assertions below. Qwen Code 0.24.0 was captured separately on
* 2026-09-18.
* These fixtures were recorded on 2026-09-18 against Copilot CLI 1.0.86,
* ACP agent 1.0.4, Grok 1.0.13, Qwen Code 0.24.0, and the Kimi Code 0.39.1
* compatibility baseline. Kimi Code 2.0.0's current ACP reference is covered
* by the dialect contract assertions below.
* Qwen Code 0.24.0 was captured separately on 2026-09-18.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
Expand All @@ -19,21 +20,23 @@ function loadFixture<T>(name: string): T {
}

describe("captured initialize fixtures", () => {
it("copilot 1.0.82 advertises loadSession and image, not close or resume", () => {
it("copilot 1.0.86 advertises load, close, MCP, and image, not resume", () => {
const init = loadFixture<AcpInitializeResponse>("copilot.initialize.json");
expect(init.protocolVersion).toBe(1);
expect(init.agentInfo?.version).toBe("1.0.86");
expect(init.agentCapabilities?.loadSession).toBe(true);
expect(init.agentCapabilities?.mcpCapabilities).toEqual({ http: true, sse: true });
expect(init.agentCapabilities?.promptCapabilities?.image).toBe(true);
expect(init.agentCapabilities?.sessionCapabilities?.list).toEqual({});
expect(init.agentCapabilities?.sessionCapabilities).not.toHaveProperty("close");
expect(init.agentCapabilities?.sessionCapabilities).toMatchObject({ close: {}, list: {} });
expect(init.agentCapabilities?.sessionCapabilities).not.toHaveProperty("resume");
// ADE still declares close and degrades on -32601 rather than killing the
// process (Copilot can host more than one session). Resume stays unclaimed.
expect(copilotDialect.sessionConfig.declared).toBe(true);
expect(copilotDialect.configOptionIds).toEqual(["mode", "allow_all"]);
expect(copilotDialect.closeStyle).toBe("close_request");
expect(copilotDialect.loadPolicy).toBe("load_only");
expect(copilotDialect.resumeSession.declared).toBe(false);
expect(copilotDialect.cancelStyle).toBe("notification");
expect(copilotDialect.imagePrompts.declared).toBe(true);
expect(copilotDialect.mcpInjection.declared).toBe(true);
});

it("grok remains first-class while preserving its honest capability gates", () => {
Expand Down Expand Up @@ -117,6 +120,7 @@ describe("captured initialize fixtures", () => {
expect(mode?.options?.map((entry) => entry.id)).toEqual([
"https://agentclientprotocol.com/protocol/session-modes#agent",
"https://agentclientprotocol.com/protocol/session-modes#plan",
"https://agentclientprotocol.com/protocol/session-modes#autopilot",
]);
expect(options.find((option) => option.id === "allow_all")?.value).toBe("off");
});
Expand Down
104 changes: 96 additions & 8 deletions apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
GROK_CLAUDE_MARKER_OVERRIDE_ENV,
GROK_SESSION_NOTIFICATION_METHOD,
GROK_YOLO_MODE_CHANGED_METHOD,
copilotPermissionModeDegradationNote,
copilotNativeModeValue,
copilotSupervisionPermissionMode,
includeCopilotSlashCommand,
KIMI_CONFIG_OPTION_IDS,
kimiDialect,
Expand Down Expand Up @@ -345,16 +348,46 @@ describe("spawn plans", () => {
expect(plan.env.COPILOT_HOME).toBe("/home/.copilot");
});

it("passes the selected model to Copilot's ACP process", () => {
const plan = copilotDialect.buildSpawnPlan({
it("copilot passes the selected model and effort as process-global ACP flags", () => {
const context = {
binaryPath: "/bin/copilot",
cwd: "/lane/worktree",
baseEnv: {},
modelId: "github-copilot/gpt-5.4",
});
reasoningEffort: "high",
};
const plan = copilotDialect.buildSpawnPlan(context);
const planWithoutModel = copilotDialect.buildSpawnPlan({ ...context, modelId: undefined });
expect(plan.args).toEqual(expect.arrayContaining(["--model", "gpt-5.4", "--effort", "high"]));
expect(hashSpawnInvocation(plan)).not.toBe(hashSpawnInvocation(planWithoutModel));
});

it.each([
["plan", "https://agentclientprotocol.com/protocol/session-modes#plan"],
["default", "https://agentclientprotocol.com/protocol/session-modes#agent"],
["auto-edit", "https://agentclientprotocol.com/protocol/session-modes#agent"],
["auto", "https://agentclientprotocol.com/protocol/session-modes#agent"],
["yolo", "https://agentclientprotocol.com/protocol/session-modes#autopilot"],
] as const)("copilot maps ADE %s to an honest 1.0.86 ACP mode", (mode, expected) => {
expect(copilotNativeModeValue(mode)).toBe(expected);
});

expect(plan.args).toContain("--model");
expect(plan.args[plan.args.indexOf("--model") + 1]).toBe("gpt-5.4");
it.each(["plan", "default", "yolo", null])("copilot only warns about an autonomy downgrade for auto modes (%s)", (mode) => {
expect(copilotPermissionModeDegradationNote(mode)).toBeNull();
});

it.each(["auto-edit", "auto"])("copilot explains its %s downgrade", (mode) => {
expect(copilotPermissionModeDegradationNote(mode)).toContain("approval-gated Agent mode");
});

it.each([
["plan", "plan"],
["default", "default"],
["auto-edit", "default"],
["auto", "default"],
["yolo", "yolo"],
] as const)("copilot supervises %s as %s", (mode, expected) => {
expect(copilotSupervisionPermissionMode(mode)).toBe(expected);
});

// ADE removed its Copilot trust pre-seed: a live three-arm experiment on
Expand Down Expand Up @@ -565,6 +598,28 @@ describe("session entry policy", () => {
expect(plan.suppressReplay).toBe(false);
});

it("falls back to load when the agent handshake omits resume", () => {
const plan = resolveAcpSessionEntry({
dialect: qwenDialect,
existingSessionId: "s1",
adeHasTranscript: true,
agentCapabilities: { loadSession: true, sessionCapabilities: { list: {} } },
});
expect(plan.mode).toBe("load");
expect(plan.suppressReplay).toBe(true);
});

it("starts a fresh session when the agent handshake omits rejoin support", () => {
const plan = resolveAcpSessionEntry({
dialect: copilotDialect,
existingSessionId: "s1",
adeHasTranscript: true,
agentCapabilities: { sessionCapabilities: { list: {} } },
});
expect(plan.mode).toBe("new");
expect(plan.suppressReplay).toBe(false);
});

it("suppresses the load replay when ADE already holds the transcript", () => {
const plan = resolveAcpSessionEntry({
dialect: copilotDialect,
Expand Down Expand Up @@ -1012,7 +1067,7 @@ describe("session config", () => {
expect([...kimiDialect.configOptionIds]).toEqual(["mode", "model", "thinking"]);
});

it.each(["grok", "copilot"] as const)(
it.each(["grok"] as const)(
"%s refuses a config option instead of sending a call it does not support",
async (providerId) => {
const harness = makeHarness(acpDialectFor(providerId));
Expand All @@ -1035,6 +1090,18 @@ describe("session config", () => {
params: { sessionId: "session-1", modelId: "gpt-5.4" },
});
});

it("copilot accepts its native mode config option", async () => {
const harness = makeHarness(copilotDialect);
harness.agent.on(ACP_METHOD.sessionSetConfigOption, () => ({ result: {} }));
const session = await withDeadline("open", harness.open());
await withDeadline("set", session.setConfigOption({
configId: "mode",
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
}));
expect(harness.agent.received.find((entry) => entry.method === ACP_METHOD.sessionSetConfigOption)?.params)
.toMatchObject({ configId: "mode", value: "https://agentclientprotocol.com/protocol/session-modes#plan" });
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1326,6 +1393,17 @@ describe("unsupervised session invariant", () => {
expect(notices(harness)).toHaveLength(0);
});

it("treats Copilot's auto-edit downgrade as approval-gated", async () => {
const harness = makeHarness(copilotDialect);
writingTurn(harness);
const session = await withDeadline("open", harness.open({ permissionMode: "auto-edit" }));
await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] }));
expect(notices(harness)).toHaveLength(1);
expect(notices(harness)[0]).toMatchObject({
message: "GitHub Copilot changed files here without asking ADE to approve. ADE's approval cards can't gate this chat.",
});
});

it("stays silent for a read-only turn, because reads never prompt anywhere", async () => {
const harness = makeHarness(grokDialect);
writingTurn(harness, "read");
Expand Down Expand Up @@ -1607,8 +1685,18 @@ describe("close and eviction", () => {
const harness = makeHarness(copilotDialect);
const session = await withDeadline("open", harness.open());
await withDeadline("close", session.close("chat ended"));
// Copilot 1.0.82 answers -32601. Degraded, not thrown. The pooled process
// stays usable for other chats.
// Older Copilot ACP builds can answer -32601. Degraded, not thrown. The
// pooled process stays usable for other chats.
expect(session.connection.isAlive()).toBe(true);
});

it("does not send close when an older Copilot handshake omits it", async () => {
const harness = makeHarness(copilotDialect, {
agentCapabilities: { loadSession: true, sessionCapabilities: { list: {} } },
});
const session = await withDeadline("open", harness.open());
await withDeadline("close", session.close("chat ended"));
expect(harness.agent.methodsReceived()).not.toContain(ACP_METHOD.sessionClose);
expect(session.connection.isAlive()).toBe(true);
});
});
Expand Down
18 changes: 15 additions & 3 deletions apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ export function behaviorOf<TBehavior>(entry: AcpCapability<TBehavior>): TBehavio
/**
* How to stop a running turn.
*
* Grok, and Copilot 1.0.82, answer a `session/cancel` REQUEST with -32601.
* They accept the same call as a notification. Qwen and Kimi accept the
* request form.
* Grok and Copilot's ACP server answer a `session/cancel` REQUEST with -32601
* on the compatibility baseline. They accept the same call as a notification.
* Qwen and Kimi accept the request form.
*/
export type AcpCancelStyle = "request" | "notification";

Expand Down Expand Up @@ -283,6 +283,15 @@ export type AcpDialectBase = {
/** Build the process spawn plan. Pure: no file system reads, no spawns. */
readonly buildSpawnPlan: (context: AcpSpawnContext) => AcpSpawnPlan;

/** Map ADE's abstract mode to the provider's native config value. */
readonly nativeModeValue?: (mode: string) => string;

/** Map ADE's requested mode to the posture the supervision guard should enforce. */
readonly supervisionPermissionMode?: (mode: string | null | undefined) => string | null | undefined;

/** Whether failure to apply the native mode must abort runtime setup. */
readonly modeSetupRequired?: boolean;

readonly cancelStyle: AcpCancelStyle;

/**
Expand Down Expand Up @@ -348,6 +357,9 @@ export type AcpDialectBase = {
*/
readonly degradationNotes: readonly string[];

/** Optional mode-specific degradation note, emitted only for that mode. */
readonly degradationNoteForMode?: (permissionMode: string | null | undefined) => string | null;

/** Optional capabilities. Present ones carry their behavior. */
readonly sessionConfig: AcpCapability<AcpSessionConfigBehavior>;
readonly modelSelection: AcpCapability<AcpModelSelectionBehavior>;
Expand Down
Loading
Loading