1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import { formatInlineContextReference } from "./lib/composerContextReferences";
4import { reviewCommentContextRecord, threadContextReference } from "./lib/composerContextRecords";
5import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
6
7import {
8 carryDisplacedCustomAnswerIntoPrompt,
9 derivePendingUserInputProgress,
10 projectPendingUserInputAnswer,
11 threadContextsReferencedByAnswer,
12 withdrawDisplacedCustomAnswerFromPrompt,
13} from "./pendingUserInput";
14
15describe("projectPendingUserInputAnswer", () => {
16 const record = threadContextRecord(
17 {
18 environmentId: EnvironmentId.make("env-answer"),
19 threadId: ThreadId.make("reference-thread"),
20 },
21 "Reference work",
22 );
23
24 it("sends thread chips as readable links with the actual thread ID", () => {
25 const answer = `Compare ${formatInlineContextReference(threadContextReference(record))}`;
26 expect(projectPendingUserInputAnswer(answer, [record])).toBe(
27 "Compare [Reference work](t3-thread://v2/env-answer/reference-thread)",
28 );
29 });
30
31 // regression: #28022 a review comment chip in an answer reached the agent as a bare link
32 it("sends review comment chips with the comment text and diff", () => {
33 const comment = reviewCommentContextRecord({
34 id: "file-comment-1",
35 sectionId: "branch",
36 sectionTitle: "Changes",
37 filePath: "notes.md",
38 startIndex: 2,
39 endIndex: 2,
40 rangeLabel: "+2",
41 text: "Rename this to line two updated",
42 diff: "@@ -0,0 +2,1 @@\n+line two changed",
43 fenceLanguage: "diff",
44 });
45 const answer = `See my note: ${formatInlineContextReference({
46 kind: "review-comment",
47 contextId: comment.contextId,
48 label: comment.label,
49 })}`;
50 const projected = projectPendingUserInputAnswer(answer, [comment]);
51 expect(projected).toContain("Rename this to line two updated");
52 expect(projected).toContain("+line two changed");
53 });
54
55 // regression: #18789 answers to pending questions lost exact text and option values
56 it("preserves option values and exact answer text", () => {
57 const options = [" first\t", "second"];
58 expect(projectPendingUserInputAnswer(options, [record])).toBe(options);
59 expect(projectPendingUserInputAnswer(" Edited answer\n", [record])).toBe(" Edited answer\n");
60 });
61});
62
63describe("threadContextsReferencedByAnswer", () => {
64 const kept = threadContextRecord(
65 { environmentId: EnvironmentId.make("env-answer"), threadId: ThreadId.make("kept-thread") },
66 "Kept work",
67 );
68 const deleted = threadContextRecord(
69 { environmentId: EnvironmentId.make("env-answer"), threadId: ThreadId.make("deleted-thread") },
70 "Deleted work",
71 );
72
73 it("preserves remaining chips without restoring deleted thread records", () => {
74 const answer = `Compare ${formatInlineContextReference(threadContextReference(kept))}`;
75 expect(threadContextsReferencedByAnswer(answer, [deleted, kept])).toEqual([kept]);
76 });
77});
78
79const singleSelectQuestion = {
80 id: "scope",
81 header: "Scope",
82 question: "What should the plan target first?",
83 options: [
84 {
85 label: "Orchestration-first",
86 description: "Focus on orchestration first",
87 },
88 ],
89 multiSelect: false,
90} as const;
91
92describe("pending user input question progress", () => {
93 const questions = [
94 singleSelectQuestion,
95 {
96 id: "compat",
97 header: "Compat",
98 question: "How strict should compatibility be?",
99 options: [
100 {
101 label: "Keep current envelope",
102 description: "Preserve current wire format",
103 },
104 ],
105 multiSelect: false,
106 },
107 ] as const;
108
109 it("counts a deliberately cleared answer as answered", () => {
110 const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" };
111 const drafts = { scope: { customAnswer: "" } };
112 expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({
113 customAnswer: "",
114 resolvedAnswer: "",
115 canAdvance: true,
116 isComplete: true,
117 });
118 });
119
120 it("derives the active question and advancement state", () => {
121 expect(
122 derivePendingUserInputProgress(
123 questions,
124 {
125 scope: {
126 selectedOptionValues: ["Orchestration-first"],
127 },
128 },
129 0,
130 ),
131 ).toMatchObject({
132 questionIndex: 0,
133 activeQuestion: questions[0],
134 selectedOptionValues: ["Orchestration-first"],
135 customAnswer: "",
136 resolvedAnswer: "Orchestration-first",
137 answeredQuestionCount: 1,
138 isLastQuestion: false,
139 isComplete: false,
140 canAdvance: true,
141 });
142 });
143});
144
145describe("carryDisplacedCustomAnswerIntoPrompt", () => {
146 // regression: #12577 clicking a question option discarded typed text
147 it("keeps the thread draft when nothing was typed into the answer", () => {
148 expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft");
149 expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft");
150 });
151
152 // regression: #12577 clicking a question option discarded typed text
153 it("moves the typed answer into an empty thread draft", () => {
154 expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe(
155 "also rename the flag",
156 );
157 });
158
159 it("appends the typed answer after an existing thread draft", () => {
160 expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe(
161 "first half\n\nsecond half",
162 );
163 });
164});
165
166describe("withdrawDisplacedCustomAnswerFromPrompt", () => {
167 // regression: #35205 an answer restored with undo stayed in the thread draft and was sent twice
168 it("takes set-aside text back out of the thread draft and leaves the rest", () => {
169 const setAside = carryDisplacedCustomAnswerIntoPrompt("", "first answer ");
170 expect(withdrawDisplacedCustomAnswerFromPrompt(setAside, "first answer ")).toBe("");
171
172 const afterDraft = carryDisplacedCustomAnswerIntoPrompt("draft", "first answer");
173 expect(withdrawDisplacedCustomAnswerFromPrompt(afterDraft, "first answer")).toBe("draft");
174
175 const beforeSecond = carryDisplacedCustomAnswerIntoPrompt(setAside, "second answer");
176 expect(
177 withdrawDisplacedCustomAnswerFromPrompt(beforeSecond, "first answer", ["second answer"]),
178 ).toBe("second answer");
179
180 const between = carryDisplacedCustomAnswerIntoPrompt(afterDraft, "second answer");
181 expect(
182 withdrawDisplacedCustomAnswerFromPrompt(between, "first answer", ["second answer"]),
183 ).toBe("draft\n\nsecond answer");
184 });
185
186 it("removes its own copy, not equal text inside a later set-aside answer", () => {
187 const first = carryDisplacedCustomAnswerIntoPrompt("", "foo");
188 const both = carryDisplacedCustomAnswerIntoPrompt(first, "bar\n\nfoo");
189 expect(withdrawDisplacedCustomAnswerFromPrompt(both, "foo", ["bar\n\nfoo"])).toBe("bar\n\nfoo");
190 });
191
192 it("leaves a thread draft that no longer holds the set-aside text where it was put", () => {
193 expect(withdrawDisplacedCustomAnswerFromPrompt("first answer, edited", "first answer")).toBe(
194 "first answer, edited",
195 );
196 expect(withdrawDisplacedCustomAnswerFromPrompt("my first answer", "first answer")).toBe(
197 "my first answer",
198 );
199 expect(withdrawDisplacedCustomAnswerFromPrompt("draft", " ")).toBe("draft");
200 });
201});