| 1 | import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts"; |
| 2 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 3 | |
| 4 | import type { AcpSessionModeState } from "./runtimeModel.ts"; |
| 5 | |
| 6 | /** |
| 7 | * Maps ACP session configuration and session modes onto T3's provider option |
| 8 | * descriptors so the existing model-options UI can drive them. |
| 9 | * |
| 10 | * Model selection stays on the dedicated model picker: `category: "model"` |
| 11 | * options are excluded here because they already surface as models. Session |
| 12 | * modes advertised through the modes API (rather than a config option) are |
| 13 | * folded into one synthetic descriptor that the ACP adapter routes to |
| 14 | * `session/set_mode`. |
| 15 | */ |
| 16 | |
| 17 | /** Synthetic descriptor ID for agents that expose modes outside config options. */ |
| 18 | export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; |
| 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 | |
| 27 | const MAX_OPTION_DESCRIPTORS = 16; |
| 28 | const MAX_OPTION_CHOICES = 64; |
| 29 | const MAX_TEXT_LENGTH = 256; |
| 30 | const MAX_DESCRIPTION_LENGTH = 1_024; |
| 31 | |
| 32 | const boundedText = (value: string | null | undefined, maximumLength: number): string => |
| 33 | (value ?? "").trim().slice(0, maximumLength); |
| 34 | |
| 35 | const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => |
| 36 | value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; |
| 37 | |
| 38 | const choiceId = (value: string): string | undefined => |
| 39 | value === "" ? ACP_EMPTY_OPTION_VALUE : boundedOpaqueValue(value, MAX_TEXT_LENGTH); |
| 40 | |
| 41 | function flattenSelectChoices( |
| 42 | options: EffectAcpSchema.SessionConfigSelectOptions, |
| 43 | ): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> { |
| 44 | return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options)); |
| 45 | } |
| 46 | |
| 47 | function selectChoices( |
| 48 | candidates: ReadonlyArray<{ |
| 49 | readonly value: string; |
| 50 | readonly name: string; |
| 51 | readonly description?: string | null | undefined; |
| 52 | }>, |
| 53 | ): ReadonlyArray<ProviderOptionChoice> { |
| 54 | const seen = new Set<string>(); |
| 55 | const choices: Array<ProviderOptionChoice> = []; |
| 56 | for (const candidate of candidates) { |
| 57 | const id = choiceId(candidate.value); |
| 58 | if (id === undefined || seen.has(id)) continue; |
| 59 | seen.add(id); |
| 60 | const description = boundedText(candidate.description, MAX_DESCRIPTION_LENGTH); |
| 61 | choices.push({ |
| 62 | id, |
| 63 | label: boundedText(candidate.name, MAX_TEXT_LENGTH) || id, |
| 64 | ...(description ? { description } : {}), |
| 65 | }); |
| 66 | if (choices.length === MAX_OPTION_CHOICES) break; |
| 67 | } |
| 68 | return choices; |
| 69 | } |
| 70 | |
| 71 | export function acpProviderOptionDescriptors(input: { |
| 72 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined; |
| 73 | readonly modeState: AcpSessionModeState | undefined; |
| 74 | }): ReadonlyArray<ProviderOptionDescriptor> { |
| 75 | const descriptors: Array<ProviderOptionDescriptor> = []; |
| 76 | const seen = new Set<string>(); |
| 77 | const mirroredModeChoiceSets: Array<ReadonlySet<string>> = []; |
| 78 | let hasModeCategory = false; |
| 79 | |
| 80 | for (const option of input.configOptions ?? []) { |
| 81 | // "model" options surface as the model list; "collaboration_mode" options |
| 82 | // are driven by T3's own plan/build interaction mode in the ACP adapter. |
| 83 | if (option.category === "model" || option.category === "collaboration_mode") { |
| 84 | continue; |
| 85 | } |
| 86 | const id = boundedOpaqueValue(option.id, MAX_TEXT_LENGTH); |
| 87 | if (id === undefined || seen.has(id)) continue; |
| 88 | seen.add(id); |
| 89 | const description = boundedText(option.description, MAX_DESCRIPTION_LENGTH); |
| 90 | const base = { |
| 91 | id, |
| 92 | label: boundedText(option.name, MAX_TEXT_LENGTH) || id, |
| 93 | ...(description ? { description } : {}), |
| 94 | } as const; |
| 95 | if (option.type === "boolean") { |
| 96 | if (option.category === "mode") hasModeCategory = true; |
| 97 | descriptors.push({ |
| 98 | ...base, |
| 99 | type: "boolean", |
| 100 | currentValue: option.currentValue, |
| 101 | }); |
| 102 | if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; |
| 103 | continue; |
| 104 | } |
| 105 | const choices = selectChoices(flattenSelectChoices(option.options)); |
| 106 | if (choices.length === 0) { |
| 107 | seen.delete(id); |
| 108 | continue; |
| 109 | } |
| 110 | if (option.category === "mode") hasModeCategory = true; |
| 111 | if (option.category === "thought_level") { |
| 112 | mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); |
| 113 | } |
| 114 | const currentValue = choiceId(option.currentValue); |
| 115 | descriptors.push({ |
| 116 | ...base, |
| 117 | type: "select", |
| 118 | options: choices, |
| 119 | ...(currentValue && choices.some((choice) => choice.id === currentValue) |
| 120 | ? { currentValue } |
| 121 | : {}), |
| 122 | }); |
| 123 | if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; |
| 124 | } |
| 125 | |
| 126 | const modeState = input.modeState; |
| 127 | if (modeState !== undefined && !hasModeCategory) { |
| 128 | const choices = selectChoices( |
| 129 | modeState.availableModes.map((mode) => ({ |
| 130 | value: mode.id, |
| 131 | name: mode.name, |
| 132 | description: mode.description, |
| 133 | })), |
| 134 | ); |
| 135 | const currentValue = choiceId(modeState.currentModeId); |
| 136 | // Some agents mirror one knob through both the modes API and a config |
| 137 | // option (codex-acp advertises its thinking levels as modes too). Skip |
| 138 | // the synthetic descriptor when an existing descriptor already exposes |
| 139 | // the same choice set, so the composer shows the knob once. |
| 140 | const duplicatesKnownModeDescriptor = mirroredModeChoiceSets.some( |
| 141 | (choiceIds) => |
| 142 | choiceIds.size === choices.length && choices.every((choice) => choiceIds.has(choice.id)), |
| 143 | ); |
| 144 | if ( |
| 145 | choices.length > 1 && |
| 146 | !duplicatesKnownModeDescriptor && |
| 147 | !seen.has(ACP_SESSION_MODE_OPTION_ID) |
| 148 | ) { |
| 149 | descriptors.push({ |
| 150 | id: ACP_SESSION_MODE_OPTION_ID, |
| 151 | label: "Mode", |
| 152 | description: "Session mode advertised by the ACP agent.", |
| 153 | type: "select", |
| 154 | options: choices, |
| 155 | ...(currentValue && choices.some((choice) => choice.id === currentValue) |
| 156 | ? { currentValue } |
| 157 | : {}), |
| 158 | }); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | return descriptors.slice(0, MAX_OPTION_DESCRIPTORS); |
| 163 | } |