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