1import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts";
2import type * as EffectAcpSchema from "effect-acp/compat";
3
4import 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. */
18export 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 */
25export const ACP_EMPTY_OPTION_VALUE = "_t3/empty";
26
27const MAX_OPTION_DESCRIPTORS = 16;
28const MAX_OPTION_CHOICES = 64;
29const MAX_TEXT_LENGTH = 256;
30const MAX_DESCRIPTION_LENGTH = 1_024;
31
32const boundedText = (value: string | null | undefined, maximumLength: number): string =>
33 (value ?? "").trim().slice(0, maximumLength);
34
35const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
36 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
37
38const choiceId = (value: string): string | undefined =>
39 value === "" ? ACP_EMPTY_OPTION_VALUE : boundedOpaqueValue(value, MAX_TEXT_LENGTH);
40
41function flattenSelectChoices(
42 options: EffectAcpSchema.SessionConfigSelectOptions,
43): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
44 return options.flatMap((candidate) => ("value" in candidate ? [candidate] : candidate.options));
45}
46
47function 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
71export 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}