diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index dc85161ad351982ef64dfab5a0c275b0bc7971d1..4e8634f9dddfe653a17cacb361cdea53fb87495f 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -97,6 +97,7 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1"; +const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const initialGrokReasoningEffort = process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; @@ -130,6 +131,7 @@ let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; let currentFast = false; +let currentAgent = ""; let authenticated = !requiresAuthentication; let promptCount = 0; let overlappingFirstPromptId: string | undefined; @@ -315,6 +317,22 @@ function configOptions(): ReadonlyArray { } return [ + // Mirrors GitHub Copilot, whose default agent is the empty-valued choice. + ...(emitEmptyDefaultAgentOption + ? [ + { + configId: "agent", + name: "Agent", + category: "_agent", + type: "select" as const, + currentValue: currentAgent, + options: [ + { value: "", name: "Copilot" }, + { value: "example", name: "example" }, + ], + }, + ] + : []), { configId: "mode", name: "Mode", @@ -791,6 +809,9 @@ const program = Effect.gen(function* () { if (request.configId === "model" && typeof request.value === "string") { currentModelId = request.value; } + if (request.configId === "agent" && typeof request.value === "string") { + currentAgent = request.value; + } if (request.configId === "reasoning" && typeof request.value === "string") { currentReasoning = request.value; } diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index cfde3367a593df0cf26c0b89b85459ebda0530f3..3d5540933164a5627b83e483cc87e29774620441 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -27,6 +27,7 @@ import { RunId, ThreadId, type OrchestrationV2ProviderThread, + type ProviderOptionDescriptor, } from "@t3tools/contracts"; import { GrokSettings } from "@t3tools/provider-grok/settings"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -58,7 +59,11 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; +import { + ACP_EMPTY_OPTION_VALUE, + ACP_SESSION_MODE_OPTION_ID, + acpProviderOptionDescriptors, +} from "@t3tools/provider-acp/server/sessionConfig"; import { extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, @@ -3644,6 +3649,105 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("keeps an agent's empty-valued default choice selectable", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const instanceId = ProviderInstanceId.make("acp-test"); + let agentDescriptor: ProviderOptionDescriptor | undefined; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION: "1" }, + protocolEvents, + }), + onSessionConfigurationUpdate: (configOptions, modeState) => + Effect.sync(() => { + agentDescriptor = acpProviderOptionDescriptors({ configOptions, modeState }).find( + (descriptor) => descriptor.id === "agent", + ); + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-empty-default-agent"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const customAgentSelection = { + instanceId, + model: "default", + options: [{ id: "agent", value: "example" }], + } satisfies ModelSelection; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-empty-default-agent"), + modelSelection: customAgentSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: customAgentSelection, + runtimePolicy, + }); + assert.deepEqual(agentDescriptor, { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: ACP_EMPTY_OPTION_VALUE, label: "Copilot" }, + { id: "example", label: "example" }, + ], + currentValue: "example", + }); + + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + modelSelection: { + instanceId, + model: "default", + options: [{ id: "agent", value: ACP_EMPTY_OPTION_VALUE }], + }, + now: yield* DateTime.now, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + + assert.equal( + agentDescriptor?.type === "select" ? agentDescriptor.currentValue : undefined, + ACP_EMPTY_OPTION_VALUE, + ); + const sentAgentValues = Array.from(yield* Queue.takeAll(protocolEvents)) + .filter( + (event) => + event.direction === "outgoing" && + rawProtocolMethod(event) === "session/set_config_option" && + rawProtocolRequestParam(event, "configId") === "agent", + ) + .map((event) => rawProtocolRequestParam(event, "value")); + assert.deepEqual(sentAgentValues, ["example", ""]); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 4c748b8a479027bda8a5797171b34d69c8c8643d..77ba680d98226a90feb3c74c8f25b7f9aa691733 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -89,7 +89,7 @@ import { resolveEmbeddedTerminalContent, type AcpClientTerminals, } from "./clientTerminals.ts"; -import { ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts"; +import { ACP_EMPTY_OPTION_VALUE, ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { t3AcpPromptWithInstructions, @@ -6331,7 +6331,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; }); } - const optionSelections = modelSelection.options ?? []; + // Restore the agent's own empty value for choices T3 stores under a + // reserved ID (see ACP_EMPTY_OPTION_VALUE). + const optionSelections = (modelSelection.options ?? []).map((selection) => + selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection, + ); const configOptions = yield* runtime.getConfigOptions; const availableConfigIds = new Set(configOptions.map((option) => option.id)); const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( diff --git a/packages/provider-acp/src/server/sessionConfig.ts b/packages/provider-acp/src/server/sessionConfig.ts index 0d2a821762e511c385736fba149b7366a53bdce3..c2ea4babd558d3c9119f2b592c6eb7a774e0bfd3 100644 --- a/packages/provider-acp/src/server/sessionConfig.ts +++ b/packages/provider-acp/src/server/sessionConfig.ts @@ -17,6 +17,13 @@ import type { AcpSessionModeState } from "./runtimeModel.ts"; /** Synthetic descriptor ID for agents that expose modes outside config options. */ export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; +/** + * Choice ID for a select value that is exactly `""`. Agents use it for a real + * choice (GitHub Copilot's default agent), but T3 option IDs must be + * non-empty. The ACP adapter maps it back to `""` before applying a selection. + */ +export const ACP_EMPTY_OPTION_VALUE = "_t3/empty"; + const MAX_OPTION_DESCRIPTORS = 16; const MAX_OPTION_CHOICES = 64; const MAX_TEXT_LENGTH = 256; @@ -28,6 +35,9 @@ const boundedText = (value: string | null | undefined, maximumLength: number): s const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; +const choiceId = (value: string): string | undefined => + value === "" ? ACP_EMPTY_OPTION_VALUE : boundedOpaqueValue(value, MAX_TEXT_LENGTH); + function flattenSelectChoices( options: EffectAcpSchema.SessionConfigSelectOptions, ): ReadonlyArray { @@ -44,7 +54,7 @@ function selectChoices( const seen = new Set(); const choices: Array = []; for (const candidate of candidates) { - const id = boundedOpaqueValue(candidate.value, MAX_TEXT_LENGTH); + const id = choiceId(candidate.value); if (id === undefined || seen.has(id)) continue; seen.add(id); const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH); @@ -101,7 +111,7 @@ export function acpProviderOptionDescriptors(input: { if (option.category === "thought_level") { mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); } - const currentValue = option.currentValue; + const currentValue = choiceId(option.currentValue); descriptors.push({ ...base, type: "select", @@ -122,7 +132,7 @@ export function acpProviderOptionDescriptors(input: { description: mode.description, })), ); - const currentValue = modeState.currentModeId; + const currentValue = choiceId(modeState.currentModeId); // Some agents mirror one knob through both the modes API and a config // option (codex-acp advertises its thinking levels as modes too). Skip // the synthetic descriptor when an existing descriptor already exposes