@sayknow-cli/coding-agent 0.5.10 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools/ask.ts CHANGED
@@ -41,7 +41,14 @@ import { appendOrMergeDeepInterviewRound, syncDeepInterviewRecorderHud } from ".
41
41
  import { deepInterviewStatePath } from "../skc-runtime/deep-interview-runtime";
42
42
  import { DEEP_INTERVIEW_INTENT_CATEGORIES, deepInterviewCharacterCount } from "../skc-runtime/deep-interview-state";
43
43
  import { renderStatusLine } from "../tui";
44
- import type { ToolSession } from ".";
44
+ import type {
45
+ AskAnswerRequest,
46
+ AskRemoteInteraction,
47
+ AskRemoteReceipt,
48
+ AskSettlement,
49
+ AskSettlementResult,
50
+ ToolSession,
51
+ } from ".";
45
52
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
46
53
  import { ToolAbortError } from "./tool-errors";
47
54
  import { assertUltragoalAskAllowed } from "./ultragoal-ask-guard";
@@ -134,50 +141,83 @@ const deepInterviewMetaBase = {
134
141
  * only exists after Round 0 has locked a manifest. Providers that only see the
135
142
  * JSON schema must be unable to combine the two.
136
143
  */
137
- const DeepInterviewMeta = z.union([
138
- // Branch order is load-bearing: the intent-carrying branches must win when
139
- // their key is present, and every branch is strict so a contract or review
140
- // attached to the wrong round fails closed instead of being silently
141
- // stripped by a laxer branch.
142
- z
143
- .object({
144
- ...deepInterviewMetaBase,
145
- round: z.literal(0).describe("the manifest is locked exactly once, at Round 0"),
146
- component: z.literal("review-topology"),
147
- intent_contract: IntentContract,
148
- })
149
- .strict()
150
- .describe("Round 0 topology confirmation carrying the locked-intent manifest proposal"),
151
- z
152
- .object({
153
- ...deepInterviewMetaBase,
154
- round: z.number().int().min(1).describe("reviews only exist after Round 0 locked a manifest"),
155
- intent_review: IntentReview,
156
- })
157
- .strict()
158
- .describe("post-Round-0 locked-intent reduction review"),
159
- z.object(deepInterviewMetaBase).strict().describe("ordinary interview round metadata"),
160
- ]);
144
+ const DeepInterviewTopologyMeta = z
145
+ .object({
146
+ ...deepInterviewMetaBase,
147
+ round: z.literal(0).describe("the manifest is locked exactly once, at Round 0"),
148
+ component: z.literal("review-topology"),
149
+ intent_contract: IntentContract,
150
+ })
151
+ .strict()
152
+ .describe("Round 0 topology confirmation carrying the locked-intent manifest proposal");
153
+ const DeepInterviewReviewMeta = z
154
+ .object({
155
+ ...deepInterviewMetaBase,
156
+ round: z.number().int().min(1).describe("reviews only exist after Round 0 locked a manifest"),
157
+ intent_review: IntentReview,
158
+ })
159
+ .strict()
160
+ .describe("post-Round-0 locked-intent reduction review");
161
+ const DeepInterviewRoundMeta = z.object(deepInterviewMetaBase).strict().describe("ordinary interview round metadata");
162
+ // Branch order is load-bearing: the intent-carrying branches must win when
163
+ // their key is present, and every branch is strict so a contract or review
164
+ // attached to the wrong round fails closed instead of being silently
165
+ // stripped by a laxer branch.
166
+ const DeepInterviewMeta = z.union([DeepInterviewTopologyMeta, DeepInterviewReviewMeta, DeepInterviewRoundMeta]);
167
+ type DeepInterviewMeta = z.infer<typeof DeepInterviewMeta>;
161
168
 
162
169
  const WorkflowGateMeta = z.object({
163
170
  stage: z.enum(["deep-interview", "ralplan", "ultragoal"]).describe("workflow gate stage"),
164
171
  kind: z.enum(["question", "approval", "execution"]).describe("workflow gate kind"),
165
172
  });
166
173
 
167
- const QuestionItem = z.object({
174
+ function createQuestionItemSchema(deepInterviewSchema: z.ZodType<DeepInterviewMeta>) {
175
+ return z.object({
176
+ id: z.string().describe("question id"),
177
+ question: z.string().describe("question text"),
178
+ options: z.array(OptionItem).describe("available options"),
179
+ multi: z.boolean().describe("allow multiple selections").optional(),
180
+ recommended: z.number().describe("recommended option index").optional(),
181
+ workflowGate: WorkflowGateMeta.describe("optional workflow gate stage/kind override").optional(),
182
+ deepInterview: deepInterviewSchema.describe("optional deep-interview round metadata").optional(),
183
+ });
184
+ }
185
+
186
+ const QuestionItem = createQuestionItemSchema(DeepInterviewMeta);
187
+ /** Round 0 of an active deep interview: only the topology confirmation may carry intent authority. */
188
+ const TopologyQuestionItem = createQuestionItemSchema(DeepInterviewTopologyMeta);
189
+ /** After Round 0 locked a manifest: ordinary rounds and reduction reviews, never a new contract. */
190
+ const PostTopologyQuestionItem = createQuestionItemSchema(z.union([DeepInterviewRoundMeta, DeepInterviewReviewMeta]));
191
+
192
+ /**
193
+ * Outside an active deep interview no deep-interview authority exists, so the
194
+ * provider-facing schema omits the metadata entirely and validation strips it.
195
+ */
196
+ const OrdinaryQuestionItem = z.object({
168
197
  id: z.string().describe("question id"),
169
198
  question: z.string().describe("question text"),
170
199
  options: z.array(OptionItem).describe("available options"),
171
200
  multi: z.boolean().describe("allow multiple selections").optional(),
172
201
  recommended: z.number().describe("recommended option index").optional(),
173
202
  workflowGate: WorkflowGateMeta.describe("optional workflow gate stage/kind override").optional(),
174
- deepInterview: DeepInterviewMeta.describe("optional deep-interview round metadata").optional(),
175
203
  });
176
204
 
177
205
  export const askSchema = z.object({
178
206
  questions: z.array(QuestionItem).min(1).describe("questions to ask"),
179
207
  });
180
208
 
209
+ const topologyAskSchema = z.object({
210
+ questions: z.array(TopologyQuestionItem).min(1).describe("questions to ask"),
211
+ });
212
+
213
+ const postTopologyAskSchema = z.object({
214
+ questions: z.array(PostTopologyQuestionItem).min(1).describe("questions to ask"),
215
+ });
216
+
217
+ const ordinaryAskSchema = z.object({
218
+ questions: z.array(OrdinaryQuestionItem).min(1).describe("questions to ask"),
219
+ });
220
+
181
221
  export type AskToolInput = z.infer<typeof askSchema>;
182
222
 
183
223
  /** Result for a single question */
@@ -208,6 +248,31 @@ export interface AskToolDetails {
208
248
 
209
249
  const OTHER_OPTION = "Other (type your own)";
210
250
  const RECOMMENDED_SUFFIX = " (Recommended)";
251
+
252
+ /** A one-shot local receipt used to normalize legacy string answer sources. */
253
+ function legacyAskReceipt(value: string): {
254
+ source: "remote";
255
+ interaction: AskRemoteInteraction;
256
+ settle(settlement: AskSettlement): Promise<AskSettlementResult>;
257
+ } {
258
+ let settled: Promise<AskSettlementResult> | undefined;
259
+ return {
260
+ interaction: { kind: "value", value },
261
+ source: "remote",
262
+ settle(settlement) {
263
+ if (!settled) {
264
+ settled = Promise.resolve(
265
+ settlement.kind === "commit"
266
+ ? { kind: "committed", ack: { status: "failed", reason: "unsupported" } }
267
+ : settlement.kind === "invalid"
268
+ ? { kind: "invalid_closed" }
269
+ : { kind: "resolved_without_commit" },
270
+ );
271
+ }
272
+ return settled;
273
+ },
274
+ };
275
+ }
211
276
  const DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS = Number.MAX_SAFE_INTEGER;
212
277
  const DEEP_INTERVIEW_RECORDER_AWAIT_TIMEOUT_MS = 250;
213
278
 
@@ -581,6 +646,11 @@ function formatQuestionResult(result: QuestionResult): string {
581
646
  // =============================================================================
582
647
 
583
648
  type AskParams = AskToolInput;
649
+ type AskParametersSchema =
650
+ | typeof ordinaryAskSchema
651
+ | typeof askSchema
652
+ | typeof topologyAskSchema
653
+ | typeof postTopologyAskSchema;
584
654
 
585
655
  /**
586
656
  * Ask tool for interactive user prompting during execution.
@@ -588,12 +658,25 @@ type AskParams = AskToolInput;
588
658
  * Allows gathering user preferences, clarifying instructions, and getting decisions
589
659
  * on implementation choices as the agent works.
590
660
  */
591
- export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
661
+ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
592
662
  readonly name = "ask";
593
663
  readonly label = "Ask";
594
664
  readonly summary = "Ask the user a clarifying question";
595
665
  readonly description: string;
596
- readonly parameters = askSchema;
666
+ /**
667
+ * Provider-facing metadata authority follows the durable deep-interview stage:
668
+ * Round 0 may lock an intent contract, later rounds may only review it, and a
669
+ * session with no active interview exposes (and accepts) no metadata at all.
670
+ * A host that does not track interview stages at all cannot fail closed on
671
+ * one, so it keeps the complete (still mutually exclusive) metadata union.
672
+ */
673
+ get parameters(): AskParametersSchema {
674
+ if (typeof this.session.getDeepInterviewAskStage !== "function") return askSchema;
675
+ const stage = this.session.getDeepInterviewAskStage();
676
+ if (stage === "topology") return topologyAskSchema;
677
+ if (stage === "post-topology") return postTopologyAskSchema;
678
+ return ordinaryAskSchema;
679
+ }
597
680
  readonly strict = true;
598
681
  /**
599
682
  * These fields are rendered to the user and carry no executable or durable
@@ -684,46 +767,157 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
684
767
  }
685
768
 
686
769
  const extensionUi = context?.ui;
770
+ let activeRemoteReceipt: AskRemoteReceipt | undefined;
771
+ let remoteGeneration = 0;
772
+ type RemoteRaceResult = {
773
+ winner: "remote";
774
+ value: string;
775
+ receipt: AskRemoteReceipt;
776
+ settlement?: AskSettlement;
777
+ };
778
+ const ignoreRemoteAnswerFailure = (error: unknown): Promise<never> => {
779
+ if (!(error instanceof Error && error.name === "AbortError")) {
780
+ logger.warn("Ask remote answer source failed", {
781
+ error: error instanceof Error ? error.message : String(error),
782
+ });
783
+ }
784
+ return new Promise<never>(() => {});
785
+ };
786
+ const settleActiveRemote = async (settlement: AskSettlement): Promise<void> => {
787
+ const receipt = activeRemoteReceipt;
788
+ activeRemoteReceipt = undefined;
789
+ if (receipt) await receipt.settle(settlement);
790
+ };
791
+ const checkedPrefix = `${theme.checkbox.checked} `;
792
+ const uncheckedPrefix = `${theme.checkbox.unchecked} `;
793
+ /** The option a displayed label stands for, without checkbox, numbering, or recommendation decoration. */
794
+ const semanticLabel = (label: string): string =>
795
+ stripRecommendedSuffix(
796
+ (label.startsWith(checkedPrefix)
797
+ ? label.slice(checkedPrefix.length)
798
+ : label.startsWith(uncheckedPrefix)
799
+ ? label.slice(uncheckedPrefix.length)
800
+ : label
801
+ ).replace(/^\s*\d+[.)]\s+/, ""),
802
+ );
803
+ /** Turn a typed or legacy remote answer into a race result, or park forever when it cannot count. */
804
+ const acceptRemoteAnswer = (
805
+ answer: AskRemoteReceipt | string | undefined,
806
+ generation: number,
807
+ resolveValue: (receipt: AskRemoteReceipt, value: string) => RemoteRaceResult,
808
+ ): RemoteRaceResult | Promise<RemoteRaceResult> => {
809
+ // undefined is not a valid remote answer (registration failed, or the local
810
+ // UI already won and aborted us): never settle the race, let the local
811
+ // selector decide instead of cancelling the ask.
812
+ if (answer === undefined) return new Promise<never>(() => {});
813
+ const receipt = typeof answer === "string" ? legacyAskReceipt(answer) : answer;
814
+ if (generation !== remoteGeneration) {
815
+ return receipt
816
+ .settle({ kind: "resolve_without_commit", reason: "aborted" })
817
+ .then(() => new Promise<never>(() => {}));
818
+ }
819
+ if (receipt.interaction.kind !== "value") {
820
+ // No remote controls are advertised, so a control interaction has nothing to
821
+ // act on: close it as invalid and keep waiting for a real answer.
822
+ return receipt
823
+ .settle({ kind: "invalid", reason: "invalid_control" })
824
+ .then(() => new Promise<never>(() => {}));
825
+ }
826
+ return resolveValue(receipt, receipt.interaction.value);
827
+ };
687
828
  const ui: UIContext = {
688
829
  select: (prompt, options, dialogOptions) => {
689
830
  if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
690
831
  const source = this.session.getAskAnswerSource?.();
691
832
  if (!source) return extensionUi.select(prompt, options, dialogOptions);
692
833
  // Race the local UI against a remote answer (e.g. a Telegram reply via the
693
- // notifications SDK) so asks can be answered without RPC mode. When the
694
- // local UI wins, abort the remote source so it stops waiting and marks the
695
- // action resolved-locally. First valid answer wins.
696
- // Race the local UI against a remote answer (e.g. a Telegram reply via the
697
834
  // notifications SDK) so asks can be answered without RPC mode. First valid
698
835
  // answer wins; the loser is aborted so neither side is left hanging:
699
836
  // - local wins -> abort the remote source (marks the action resolved-locally)
700
837
  // - remote wins -> abort the local selector so the TUI dialog actually closes
838
+ // Typed sources (`awaitAnswerRequest`) hand back a receipt that is settled once
839
+ // the answer is accepted, so SDK-routed interactions commit durably.
701
840
  const remoteController = new AbortController();
702
841
  const localController = new AbortController();
703
- // Propagate an external cancel (the tool's signal) to the local selector too.
842
+ const generation = ++remoteGeneration;
843
+ // Propagate external cancellation to both race legs and invalidate late replies.
704
844
  const toolSignal = dialogOptions?.signal;
845
+ const abortRace = () => {
846
+ if (generation === remoteGeneration) remoteGeneration++;
847
+ localController.abort();
848
+ remoteController.abort();
849
+ };
705
850
  if (toolSignal) {
706
- if (toolSignal.aborted) localController.abort();
707
- else toolSignal.addEventListener("abort", () => localController.abort(), { once: true });
851
+ if (toolSignal.aborted) abortRace();
852
+ else toolSignal.addEventListener("abort", abortRace, { once: true });
708
853
  }
709
- const remote = source.awaitAnswer(prompt, options, remoteController.signal).then(answer => {
710
- // undefined is not a valid remote answer (registration failed, or the local
711
- // UI already won and aborted us): never settle the race, let the local
712
- // selector decide instead of cancelling the ask.
713
- if (answer === undefined) return new Promise<string | undefined>(() => {});
714
- localController.abort();
715
- return answer;
716
- });
854
+ const request: AskAnswerRequest = { question: prompt, options, interaction: "selector", controls: [] };
855
+ const remote = (
856
+ source.awaitAnswerRequest
857
+ ? source.awaitAnswerRequest(request, remoteController.signal)
858
+ : source.awaitAnswer(prompt, options, remoteController.signal)
859
+ )
860
+ .then(answer =>
861
+ acceptRemoteAnswer(answer, generation, (receipt, remoteValue) => {
862
+ const selectedValue =
863
+ options.find(
864
+ option =>
865
+ option === remoteValue ||
866
+ option === `${checkedPrefix}${remoteValue}` ||
867
+ option === `${uncheckedPrefix}${remoteValue}`,
868
+ ) ?? remoteValue;
869
+ if (semanticLabel(selectedValue) === OTHER_OPTION) {
870
+ // "Other" only transitions to the custom editor; the answer arrives there.
871
+ return {
872
+ winner: "remote" as const,
873
+ value: selectedValue,
874
+ receipt,
875
+ settlement: { kind: "resolve_without_commit", reason: "other_transition" },
876
+ };
877
+ }
878
+ if (
879
+ (selectedValue.startsWith(checkedPrefix) || selectedValue.startsWith(uncheckedPrefix)) &&
880
+ options.includes(selectedValue)
881
+ ) {
882
+ // Multi-select: a listed entry toggles and the question is shown again.
883
+ return {
884
+ winner: "remote" as const,
885
+ value: selectedValue,
886
+ receipt,
887
+ settlement: { kind: "resolve_without_commit", reason: "toggle" },
888
+ };
889
+ }
890
+ return { winner: "remote" as const, value: selectedValue, receipt };
891
+ }),
892
+ )
893
+ .catch(ignoreRemoteAnswerFailure);
717
894
  const local = extensionUi
718
895
  .select(prompt, options, { ...dialogOptions, signal: localController.signal })
719
896
  .then(answer => {
897
+ if (generation === remoteGeneration) remoteGeneration++;
720
898
  remoteController.abort();
721
- return answer;
899
+ return { winner: "local" as const, value: answer };
900
+ })
901
+ .catch(error => {
902
+ if (generation === remoteGeneration) remoteGeneration++;
903
+ remoteController.abort();
904
+ throw error;
722
905
  });
723
906
  // The losing selector may reject when aborted after the race already settled;
724
907
  // swallow that so it is not an unhandled rejection (the race result is unaffected).
725
908
  void local.catch(() => undefined);
726
- return Promise.race([local, remote]);
909
+ return Promise.race([local, remote]).then(async result => {
910
+ if (result.winner === "remote") {
911
+ localController.abort();
912
+ if (result.settlement) await result.receipt.settle(result.settlement);
913
+ else activeRemoteReceipt = result.receipt;
914
+ } else {
915
+ void remote.then(remoteResult =>
916
+ remoteResult.receipt.settle({ kind: "resolve_without_commit", reason: "aborted" }),
917
+ );
918
+ }
919
+ return result.value;
920
+ });
727
921
  },
728
922
  editor: (title, prefill, dialogOptions, editorOptions) => {
729
923
  if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
@@ -734,24 +928,60 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
734
928
  // reply) instead of blocking on the local-only editor. Mirrors `select`.
735
929
  const remoteController = new AbortController();
736
930
  const localController = new AbortController();
931
+ const generation = ++remoteGeneration;
737
932
  const toolSignal = dialogOptions?.signal;
933
+ const abortRace = () => {
934
+ if (generation === remoteGeneration) remoteGeneration++;
935
+ localController.abort();
936
+ remoteController.abort();
937
+ };
738
938
  if (toolSignal) {
739
- if (toolSignal.aborted) localController.abort();
740
- else toolSignal.addEventListener("abort", () => localController.abort(), { once: true });
939
+ if (toolSignal.aborted) abortRace();
940
+ else toolSignal.addEventListener("abort", abortRace, { once: true });
741
941
  }
742
- const remote = source.awaitAnswer(title, [], remoteController.signal).then(answer => {
743
- if (answer === undefined) return new Promise<string | undefined>(() => {});
744
- localController.abort();
745
- return answer;
746
- });
942
+ const request: AskAnswerRequest = {
943
+ question: title,
944
+ options: [],
945
+ interaction: "custom_editor",
946
+ controls: [],
947
+ };
948
+ const remote = (
949
+ source.awaitAnswerRequest
950
+ ? source.awaitAnswerRequest(request, remoteController.signal)
951
+ : source.awaitAnswer(title, [], remoteController.signal)
952
+ )
953
+ .then(answer =>
954
+ acceptRemoteAnswer(answer, generation, (receipt, value) => ({
955
+ winner: "remote" as const,
956
+ value,
957
+ receipt,
958
+ })),
959
+ )
960
+ .catch(ignoreRemoteAnswerFailure);
747
961
  const local = extensionUi
748
962
  .editor(title, prefill, { ...(dialogOptions ?? {}), signal: localController.signal }, editorOptions)
749
963
  .then(answer => {
964
+ if (generation === remoteGeneration) remoteGeneration++;
750
965
  remoteController.abort();
751
- return answer;
966
+ return { winner: "local" as const, value: answer };
967
+ })
968
+ .catch(error => {
969
+ if (generation === remoteGeneration) remoteGeneration++;
970
+ remoteController.abort();
971
+ throw error;
752
972
  });
753
973
  void local.catch(() => undefined);
754
- return Promise.race([local, remote]);
974
+ return Promise.race([local, remote]).then(result => {
975
+ if (result.winner === "remote") {
976
+ activeRemoteReceipt = result.receipt;
977
+ localController.abort();
978
+ } else {
979
+ void remote.then(remoteResult =>
980
+ remoteResult.receipt.settle({ kind: "resolve_without_commit", reason: "aborted" }),
981
+ );
982
+ }
983
+ return result.value;
984
+ });
755
985
  },
756
986
  };
757
987
 
@@ -809,60 +1039,88 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
809
1039
  timedOut: false,
810
1040
  };
811
1041
  }
812
- try {
813
- const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
814
- const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
815
- const displayQuestion = deepInterviewPrompt ?? q.question;
816
- const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
817
- const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
818
- const initialSelection =
819
- shouldNumberOptions && options?.previous
820
- ? {
821
- ...options.previous,
822
- selectedOptions: options.previous.selectedOptions.map(selected => {
823
- const rawIndex = rawOptionLabels.indexOf(selected);
824
- return rawIndex >= 0 ? (optionLabels[rawIndex] ?? selected) : selected;
825
- }),
826
- }
827
- : options?.previous;
828
- const {
829
- selectedOptions: displaySelectedOptions,
830
- customInput,
831
- navigation,
832
- cancelled,
833
- timedOut,
834
- } = await askSingleQuestion(ui, displayQuestion, optionLabels, q.multi ?? false, {
835
- recommended: q.recommended,
836
- timeout: timeout ?? undefined,
837
- signal,
838
- initialSelection,
839
- navigation: options?.navigation,
840
- scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
841
- otherOptionLabel: shouldNumberOptions
842
- ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
843
- : undefined,
844
- });
845
- const selectedOptions = shouldNumberOptions
846
- ? displaySelectedOptions.map(selected => {
847
- const displayIndex = optionLabels.indexOf(selected);
848
- return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
849
- })
850
- : displaySelectedOptions;
851
- return {
852
- optionLabels: rawOptionLabels,
853
- selectedOptions,
854
- customInput,
855
- clarificationQuestion: undefined as string | undefined,
856
- navigation,
857
- cancelled,
858
- timedOut,
859
- };
860
- } catch (error) {
861
- if (error instanceof Error && error.name === "AbortError") {
862
- throw new ToolAbortError("Ask input was cancelled");
1042
+ for (;;)
1043
+ try {
1044
+ const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
1045
+ const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
1046
+ const displayQuestion = deepInterviewPrompt ?? q.question;
1047
+ const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
1048
+ const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
1049
+ const initialSelection =
1050
+ shouldNumberOptions && options?.previous
1051
+ ? {
1052
+ ...options.previous,
1053
+ selectedOptions: options.previous.selectedOptions.map(selected => {
1054
+ const rawIndex = rawOptionLabels.indexOf(selected);
1055
+ return rawIndex >= 0 ? (optionLabels[rawIndex] ?? selected) : selected;
1056
+ }),
1057
+ }
1058
+ : options?.previous;
1059
+ const {
1060
+ selectedOptions: displaySelectedOptions,
1061
+ customInput,
1062
+ navigation,
1063
+ cancelled,
1064
+ timedOut,
1065
+ } = await askSingleQuestion(ui, displayQuestion, optionLabels, q.multi ?? false, {
1066
+ recommended: q.recommended,
1067
+ timeout: timeout ?? undefined,
1068
+ signal,
1069
+ initialSelection,
1070
+ navigation: options?.navigation,
1071
+ scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
1072
+ otherOptionLabel: shouldNumberOptions
1073
+ ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
1074
+ : undefined,
1075
+ });
1076
+ const selectedOptions = shouldNumberOptions
1077
+ ? displaySelectedOptions.map(selected => {
1078
+ const displayIndex = optionLabels.indexOf(selected);
1079
+ return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
1080
+ })
1081
+ : displaySelectedOptions;
1082
+ if (activeRemoteReceipt) {
1083
+ // A remote answer won this question: settle its receipt with the outcome so
1084
+ // the answer source can acknowledge (commit) or release it.
1085
+ const settlement: AskSettlement =
1086
+ customInput !== undefined && customInput.trim().length === 0
1087
+ ? { kind: "invalid", reason: "empty_custom" }
1088
+ : cancelled
1089
+ ? { kind: "resolve_without_commit", reason: "cancelled" }
1090
+ : timedOut
1091
+ ? { kind: "resolve_without_commit", reason: "timed_out" }
1092
+ : navigation === "back"
1093
+ ? { kind: "resolve_without_commit", reason: "back_navigation" }
1094
+ : navigation === "forward" &&
1095
+ selectedOptions.length === 0 &&
1096
+ (customInput === undefined || customInput.trim().length === 0)
1097
+ ? { kind: "resolve_without_commit", reason: "empty_navigation" }
1098
+ : selectedOptions.length > 0 || (customInput?.trim().length ?? 0) > 0
1099
+ ? { kind: "commit" }
1100
+ : { kind: "resolve_without_commit", reason: "cancelled" };
1101
+ await settleActiveRemote(settlement);
1102
+ // An empty remote custom answer is rejected, not recorded: ask again.
1103
+ if (settlement.kind === "invalid") continue;
1104
+ }
1105
+ return {
1106
+ optionLabels: rawOptionLabels,
1107
+ selectedOptions,
1108
+ customInput,
1109
+ clarificationQuestion: undefined as string | undefined,
1110
+ navigation,
1111
+ cancelled,
1112
+ timedOut,
1113
+ };
1114
+ } catch (error) {
1115
+ await settleActiveRemote({
1116
+ kind: "resolve_without_commit",
1117
+ reason: error instanceof Error && error.name === "AbortError" ? "aborted" : "exception",
1118
+ });
1119
+ if (error instanceof Error && error.name === "AbortError") {
1120
+ throw new ToolAbortError("Ask input was cancelled");
1121
+ }
1122
+ throw error;
863
1123
  }
864
- throw error;
865
- }
866
1124
  };
867
1125
 
868
1126
  if (params.questions.length === 1) {
@@ -89,7 +89,10 @@ export interface BisectToolDetails {
89
89
  reason?: string;
90
90
  }
91
91
 
92
- const FIRST_BAD_RE = /^([0-9a-f]{7,40}) is the first bad commit$/m;
92
+ // git < 2.55 prints "<sha> is the first bad commit"; git >= 2.55 quotes the
93
+ // term: "<sha> is the first 'bad' commit". Accept both so convergence is
94
+ // detected regardless of the host git version.
95
+ const FIRST_BAD_RE = /^([0-9a-f]{7,40}) is the first '?bad'? commit$/m;
93
96
  const ONLY_SKIPPED_RE = /only '?skip'?ped commits left to test/i;
94
97
 
95
98
  /** Parse the culprit SHA from `git bisect good|bad` output, or null when not yet converged. */