authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:01:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:01:34-07:00
log3a6ab583d2799c9c904d8d0943afba60ec2cc5f0
tree813e4a19e7034788d10b0b2e273eca58b7ec3d5b
parent6edd44f405bf874a85d8d4f7559149a42bf6c547
signature Signed by PGP key B5690EEEBB952194

fix(acp): Copilot's default agent no longer disappears from the Agent menu (#19330)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 145 insertions(+), 6 deletions(-)

apps/server/scripts/acp-mock-agent.ts+21
......@@ -97,6 +97,7 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
9797const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1";
9898const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
9999const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1";
100const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1";
100101const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
101102const initialGrokReasoningEffort =
102103 process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
......@@ -130,6 +131,7 @@ let parameterizedModelPicker = false;
130131let currentReasoning = "medium";
131132let currentContext = "272k";
132133let currentFast = false;
134let currentAgent = "";
133135let authenticated = !requiresAuthentication;
134136let promptCount = 0;
135137let overlappingFirstPromptId: string | undefined;
......@@ -315,6 +317,22 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
315317 }
316318
317319 return [
320 // Mirrors GitHub Copilot, whose default agent is the empty-valued choice.
321 ...(emitEmptyDefaultAgentOption
322 ? [
323 {
324 configId: "agent",
325 name: "Agent",
326 category: "_agent",
327 type: "select" as const,
328 currentValue: currentAgent,
329 options: [
330 { value: "", name: "Copilot" },
331 { value: "example", name: "example" },
332 ],
333 },
334 ]
335 : []),
318336 {
319337 configId: "mode",
320338 name: "Mode",
......@@ -791,6 +809,9 @@ const program = Effect.gen(function* () {
791809 if (request.configId === "model" && typeof request.value === "string") {
792810 currentModelId = request.value;
793811 }
812 if (request.configId === "agent" && typeof request.value === "string") {
813 currentAgent = request.value;
814 }
794815 if (request.configId === "reasoning" && typeof request.value === "string") {
795816 currentReasoning = request.value;
796817 }
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+105-1
......@@ -27,6 +27,7 @@ import {
2727 RunId,
2828 ThreadId,
2929 type OrchestrationV2ProviderThread,
30 type ProviderOptionDescriptor,
3031} from "@t3tools/contracts";
3132import { GrokSettings } from "@t3tools/provider-grok/settings";
3233import * as HostProcess from "@t3tools/shared/HostProcess";
......@@ -58,7 +59,11 @@ import type * as EffectAcpSchema from "effect-acp/compat";
5859import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
5960import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
6061import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
61import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
62import {
63 ACP_EMPTY_OPTION_VALUE,
64 ACP_SESSION_MODE_OPTION_ID,
65 acpProviderOptionDescriptors,
66} from "@t3tools/provider-acp/server/sessionConfig";
6267import {
6368 extractXAiAcpSubagentEndNotice,
6469 extractXAiAcpSubagentUpdate,
......@@ -3644,6 +3649,105 @@ describe("AcpAdapterV2", () => {
36443649 }).pipe(Effect.provide(layerTest), Effect.scoped),
36453650 );
36463651
3652 it.effect("keeps an agent's empty-valued default choice selectable", () =>
3653 Effect.gen(function* () {
3654 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3655 const path = yield* Path.Path;
3656 const selfInvocation = yield* resolveSelfInvocation();
3657 const mockAgentPath = yield* path.fromFileUrl(
3658 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3659 );
3660 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3661 const instanceId = ProviderInstanceId.make("acp-test");
3662 let agentDescriptor: ProviderOptionDescriptor | undefined;
3663 const adapter = yield* makeAcpAdapterV2({
3664 instanceId,
3665 flavor: {
3666 driver: ACP_TEST_DRIVER,
3667 capabilities: AcpProviderCapabilitiesV2,
3668 makeRuntime: makeMockRuntime({
3669 childProcessSpawner,
3670 mockAgentPath,
3671 environment: { T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION: "1" },
3672 protocolEvents,
3673 }),
3674 onSessionConfigurationUpdate: (configOptions, modeState) =>
3675 Effect.sync(() => {
3676 agentDescriptor = acpProviderOptionDescriptors({ configOptions, modeState }).find(
3677 (descriptor) => descriptor.id === "agent",
3678 );
3679 }),
3680 },
3681 selfInvocation,
3682 });
3683 const threadId = ThreadId.make("thread-acp-empty-default-agent");
3684 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3685 runtimeMode: "full-access",
3686 interactionMode: "default",
3687 cwd: process.cwd(),
3688 });
3689 const customAgentSelection = {
3690 instanceId,
3691 model: "default",
3692 options: [{ id: "agent", value: "example" }],
3693 } satisfies ModelSelection;
3694 const runtime = yield* adapter.openSession({
3695 threadId,
3696 providerSessionId: ProviderSessionId.make("provider-session-acp-empty-default-agent"),
3697 modelSelection: customAgentSelection,
3698 runtimePolicy,
3699 });
3700 const providerThread = yield* runtime.ensureThread({
3701 threadId,
3702 modelSelection: customAgentSelection,
3703 runtimePolicy,
3704 });
3705 assert.deepEqual(agentDescriptor, {
3706 id: "agent",
3707 label: "Agent",
3708 type: "select",
3709 options: [
3710 { id: ACP_EMPTY_OPTION_VALUE, label: "Copilot" },
3711 { id: "example", label: "example" },
3712 ],
3713 currentValue: "example",
3714 });
3715
3716 yield* runtime.startTurn(
3717 makeTurnInput({
3718 threadId,
3719 providerThread,
3720 instanceId,
3721 runtimePolicy,
3722 modelSelection: {
3723 instanceId,
3724 model: "default",
3725 options: [{ id: "agent", value: ACP_EMPTY_OPTION_VALUE }],
3726 },
3727 now: yield* DateTime.now,
3728 }),
3729 );
3730 yield* runtime.events.pipe(
3731 Stream.filter((event) => event.type === "turn.terminal"),
3732 Stream.runHead,
3733 );
3734
3735 assert.equal(
3736 agentDescriptor?.type === "select" ? agentDescriptor.currentValue : undefined,
3737 ACP_EMPTY_OPTION_VALUE,
3738 );
3739 const sentAgentValues = Array.from(yield* Queue.takeAll(protocolEvents))
3740 .filter(
3741 (event) =>
3742 event.direction === "outgoing" &&
3743 rawProtocolMethod(event) === "session/set_config_option" &&
3744 rawProtocolRequestParam(event, "configId") === "agent",
3745 )
3746 .map((event) => rawProtocolRequestParam(event, "value"));
3747 assert.deepEqual(sentAgentValues, ["example", ""]);
3748 }).pipe(Effect.provide(layerTest), Effect.scoped),
3749 );
3750
36473751 it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () =>
36483752 Effect.gen(function* () {
36493753 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
packages/provider-acp/src/server/adapter.ts+6-2
......@@ -89,7 +89,7 @@ import {
8989 resolveEmbeddedTerminalContent,
9090 type AcpClientTerminals,
9191} from "./clientTerminals.ts";
92import { ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts";
92import { ACP_EMPTY_OPTION_VALUE, ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts";
9393import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
9494import {
9595 t3AcpPromptWithInstructions,
......@@ -6331,7 +6331,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
63316331 };
63326332 });
63336333 }
6334 const optionSelections = modelSelection.options ?? [];
6334 // Restore the agent's own empty value for choices T3 stores under a
6335 // reserved ID (see ACP_EMPTY_OPTION_VALUE).
6336 const optionSelections = (modelSelection.options ?? []).map((selection) =>
6337 selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection,
6338 );
63356339 const configOptions = yield* runtime.getConfigOptions;
63366340 const availableConfigIds = new Set(configOptions.map((option) => option.id));
63376341 const hasNativeConfigWithSyntheticModeId = availableConfigIds.has(
packages/provider-acp/src/server/sessionConfig.ts+13-3
......@@ -17,6 +17,13 @@ import type { AcpSessionModeState } from "./runtimeModel.ts";
1717/** Synthetic descriptor ID for agents that expose modes outside config options. */
1818export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode";
1919
20/**
21 * Choice ID for a select value that is exactly `""`. Agents use it for a real
22 * choice (GitHub Copilot's default agent), but T3 option IDs must be
23 * non-empty. The ACP adapter maps it back to `""` before applying a selection.
24 */
25export const ACP_EMPTY_OPTION_VALUE = "_t3/empty";
26
2027const MAX_OPTION_DESCRIPTORS = 16;
2128const MAX_OPTION_CHOICES = 64;
2229const MAX_TEXT_LENGTH = 256;
......@@ -28,6 +35,9 @@ const boundedText = (value: string | null | undefined, maximumLength: number): s
2835const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
2936 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
3037
38const choiceId = (value: string): string | undefined =>
39 value === "" ? ACP_EMPTY_OPTION_VALUE : boundedOpaqueValue(value, MAX_TEXT_LENGTH);
40
3141function flattenSelectChoices(
3242 options: EffectAcpSchema.SessionConfigSelectOptions,
3343): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
......@@ -44,7 +54,7 @@ function selectChoices(
4454 const seen = new Set<string>();
4555 const choices: Array<ProviderOptionChoice> = [];
4656 for (const candidate of candidates) {
47 const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH);
57 const id = choiceId(candidate.value);
4858 if (id === undefined || seen.has(id)) continue;
4959 seen.add(id);
5060 const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH);
......@@ -101,7 +111,7 @@ export function acpProviderOptionDescriptors(input: {
101111 if (option.category === "thought_level") {
102112 mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id)));
103113 }
104 const currentValue = option.currentValue;
114 const currentValue = choiceId(option.currentValue);
105115 descriptors.push({
106116 ...base,
107117 type: "select",
......@@ -122,7 +132,7 @@ export function acpProviderOptionDescriptors(input: {
122132 description: mode.description,
123133 })),
124134 );
125 const currentValue = modeState.currentModeId;
135 const currentValue = choiceId(modeState.currentModeId);
126136 // Some agents mirror one knob through both the modes API and a config
127137 // option (codex-acp advertises its thinking levels as modes too). Skip
128138 // the synthetic descriptor when an existing descriptor already exposes