| 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,6 +97,7 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 97 | const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; | 97 | const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; |
| 98 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 98 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 99 | const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1"; | 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 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 101 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 101 | const initialGrokReasoningEffort = | 102 | const 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; |
| 130 | let currentReasoning = "medium"; | 131 | let currentReasoning = "medium"; |
| 131 | let currentContext = "272k"; | 132 | let currentContext = "272k"; |
| 132 | let currentFast = false; | 133 | let currentFast = false; |
| 134 | let currentAgent = ""; | ||
| 133 | let authenticated = !requiresAuthentication; | 135 | let authenticated = !requiresAuthentication; |
| 134 | let promptCount = 0; | 136 | let promptCount = 0; |
| 135 | let overlappingFirstPromptId: string | undefined; | 137 | let overlappingFirstPromptId: string | undefined; |
| ... | @@ -315,6 +317,22 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> { | ... | @@ -315,6 +317,22 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> { |
| 315 | } | 317 | } |
| 316 | 318 | ||
| 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"; |
| 31 | import { GrokSettings } from "@t3tools/provider-grok/settings"; | 32 | import { GrokSettings } from "@t3tools/provider-grok/settings"; |
| 32 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 33 | import * 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"; |
| 58 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; | 59 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 59 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; | 60 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 60 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | 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 | import { | 67 | import { |
| 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 | ); |
| 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 | 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"; |
| 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 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 93 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 94 | import { | 94 | import { |
| 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. */ |
| 18 | export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; | 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 | const MAX_OPTION_DESCRIPTORS = 16; | 27 | const MAX_OPTION_DESCRIPTORS = 16; |
| 21 | const MAX_OPTION_CHOICES = 64; | 28 | const MAX_OPTION_CHOICES = 64; |
| 22 | const MAX_TEXT_LENGTH = 256; | 29 | const 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 |
| 28 | const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => | 35 | const 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; |
| 30 | 37 | ||
| 38 | const choiceId = (value: string): string | undefined => | ||
| 39 | value === "" ? ACP_EMPTY_OPTION_VALUE : boundedOpaqueValue(value, MAX_TEXT_LENGTH); | ||
| 40 | |||
| 31 | function flattenSelectChoices( | 41 | function 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 config | 136 | // Some agents mirror one knob through both the modes API and a config |
| 127 | // option (codex-acp advertises its thinking levels as modes too). Skip | 137 | // option (codex-acp advertises its thinking levels as modes too). Skip |
| 128 | // the synthetic descriptor when an existing descriptor already exposes | 138 | // the synthetic descriptor when an existing descriptor already exposes |