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";...@@ -97,6 +97,7 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
97const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1";97const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1";
98const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";98const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
99const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1";99const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1";
100const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1";
100const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;101const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
101const initialGrokReasoningEffort =102const initialGrokReasoningEffort =
102 process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;103 process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
...@@ -130,6 +131,7 @@ let parameterizedModelPicker = false;...@@ -130,6 +131,7 @@ let parameterizedModelPicker = false;
130let currentReasoning = "medium";131let currentReasoning = "medium";
131let currentContext = "272k";132let currentContext = "272k";
132let currentFast = false;133let currentFast = false;
134let currentAgent = "";
133let authenticated = !requiresAuthentication;135let authenticated = !requiresAuthentication;
134let promptCount = 0;136let promptCount = 0;
135let overlappingFirstPromptId: string | undefined;137let overlappingFirstPromptId: string | undefined;
...@@ -315,6 +317,22 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {...@@ -315,6 +317,22 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
315 }317 }
316318
317 return [319 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 : []),
318 {336 {
319 configId: "mode",337 configId: "mode",
320 name: "Mode",338 name: "Mode",
...@@ -791,6 +809,9 @@ const program = Effect.gen(function* () {...@@ -791,6 +809,9 @@ const program = Effect.gen(function* () {
791 if (request.configId === "model" && typeof request.value === "string") {809 if (request.configId === "model" && typeof request.value === "string") {
792 currentModelId = request.value;810 currentModelId = request.value;
793 }811 }
812 if (request.configId === "agent" && typeof request.value === "string") {
813 currentAgent = request.value;
814 }
794 if (request.configId === "reasoning" && typeof request.value === "string") {815 if (request.configId === "reasoning" && typeof request.value === "string") {
795 currentReasoning = request.value;816 currentReasoning = request.value;
796 }817 }
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+105-1
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 RunId,27 RunId,
28 ThreadId,28 ThreadId,
29 type OrchestrationV2ProviderThread,29 type OrchestrationV2ProviderThread,
30 type ProviderOptionDescriptor,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
31import { GrokSettings } from "@t3tools/provider-grok/settings";32import { GrokSettings } from "@t3tools/provider-grok/settings";
32import * as HostProcess from "@t3tools/shared/HostProcess";33import * as HostProcess from "@t3tools/shared/HostProcess";
...@@ -58,7 +59,11 @@ import type * as EffectAcpSchema from "effect-acp/compat";...@@ -58,7 +59,11 @@ import type * as EffectAcpSchema from "effect-acp/compat";
58import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";59import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
59import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";60import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
60import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";61import * 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";
62import {67import {
63 extractXAiAcpSubagentEndNotice,68 extractXAiAcpSubagentEndNotice,
64 extractXAiAcpSubagentUpdate,69 extractXAiAcpSubagentUpdate,
...@@ -3644,6 +3649,105 @@ describe("AcpAdapterV2", () => {...@@ -3644,6 +3649,105 @@ describe("AcpAdapterV2", () => {
3644 }).pipe(Effect.provide(layerTest), Effect.scoped),3649 }).pipe(Effect.provide(layerTest), Effect.scoped),
3645 );3650 );
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
3647 it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () =>3751 it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () =>
3648 Effect.gen(function* () {3752 Effect.gen(function* () {
3649 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;3753 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
packages/provider-acp/src/server/adapter.ts+6-2
...@@ -89,7 +89,7 @@ import {...@@ -89,7 +89,7 @@ import {
89 resolveEmbeddedTerminalContent,89 resolveEmbeddedTerminalContent,
90 type AcpClientTerminals,90 type AcpClientTerminals,
91} from "./clientTerminals.ts";91} 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";
93import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";93import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
94import {94import {
95 t3AcpPromptWithInstructions,95 t3AcpPromptWithInstructions,
...@@ -6331,7 +6331,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (...@@ -6331,7 +6331,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
6331 };6331 };
6332 });6332 });
6333 }6333 }
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 );
6335 const configOptions = yield* runtime.getConfigOptions;6339 const configOptions = yield* runtime.getConfigOptions;
6336 const availableConfigIds = new Set(configOptions.map((option) => option.id));6340 const availableConfigIds = new Set(configOptions.map((option) => option.id));
6337 const hasNativeConfigWithSyntheticModeId = availableConfigIds.has(6341 const hasNativeConfigWithSyntheticModeId = availableConfigIds.has(
packages/provider-acp/src/server/sessionConfig.ts+13-3
...@@ -17,6 +17,13 @@ import type { AcpSessionModeState } from "./runtimeModel.ts";...@@ -17,6 +17,13 @@ import type { AcpSessionModeState } from "./runtimeModel.ts";
17/** Synthetic descriptor ID for agents that expose modes outside config options. */17/** Synthetic descriptor ID for agents that expose modes outside config options. */
18export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode";18export 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
20const MAX_OPTION_DESCRIPTORS = 16;27const MAX_OPTION_DESCRIPTORS = 16;
21const MAX_OPTION_CHOICES = 64;28const MAX_OPTION_CHOICES = 64;
22const MAX_TEXT_LENGTH = 256;29const MAX_TEXT_LENGTH = 256;
...@@ -28,6 +35,9 @@ const boundedText = (value: string | null | undefined, maximumLength: number): s...@@ -28,6 +35,9 @@ const boundedText = (value: string | null | undefined, maximumLength: number): s
28const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>35const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
29 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;36 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
31function flattenSelectChoices(41function flattenSelectChoices(
32 options: EffectAcpSchema.SessionConfigSelectOptions,42 options: EffectAcpSchema.SessionConfigSelectOptions,
33): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {43): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
...@@ -44,7 +54,7 @@ function selectChoices(...@@ -44,7 +54,7 @@ function selectChoices(
44 const seen = new Set<string>();54 const seen = new Set<string>();
45 const choices: Array<ProviderOptionChoice> = [];55 const choices: Array<ProviderOptionChoice> = [];
46 for (const candidate of candidates) {56 for (const candidate of candidates) {
47 const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH);57 const id = choiceId(candidate.value);
48 if (id === undefined || seen.has(id)) continue;58 if (id === undefined || seen.has(id)) continue;
49 seen.add(id);59 seen.add(id);
50 const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH);60 const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH);
...@@ -101,7 +111,7 @@ export function acpProviderOptionDescriptors(input: {...@@ -101,7 +111,7 @@ export function acpProviderOptionDescriptors(input: {
101 if (option.category === "thought_level") {111 if (option.category === "thought_level") {
102 mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id)));112 mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id)));
103 }113 }
104 const currentValue = option.currentValue;114 const currentValue = choiceId(option.currentValue);
105 descriptors.push({115 descriptors.push({
106 ...base,116 ...base,
107 type: "select",117 type: "select",
...@@ -122,7 +132,7 @@ export function acpProviderOptionDescriptors(input: {...@@ -122,7 +132,7 @@ export function acpProviderOptionDescriptors(input: {
122 description: mode.description,132 description: mode.description,
123 })),133 })),
124 );134 );
125 const currentValue = modeState.currentModeId;135 const currentValue = choiceId(modeState.currentModeId);
126 // Some agents mirror one knob through both the modes API and a config136 // Some agents mirror one knob through both the modes API and a config
127 // option (codex-acp advertises its thinking levels as modes too). Skip137 // option (codex-acp advertises its thinking levels as modes too). Skip
128 // the synthetic descriptor when an existing descriptor already exposes138 // the synthetic descriptor when an existing descriptor already exposes