1import {
2 ASSISTANT_CITATION_MAX_TEXT_LENGTH,
3 EnvironmentId,
4 MessageId,
5 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
6 ThreadId,
7} from "@t3tools/contracts";
8import {
9 expandAssistantCitationsForProvider,
10 serializeAssistantCitation,
11} from "@t3tools/shared/assistantCitations";
12import { describe, expect, it, vi } from "vite-plus/test";
13
14import { submitComposerDraft } from "./composerSubmission";
15
16const assistantCitation = {
17 version: 1 as const,
18 environmentId: EnvironmentId.make("source-environment"),
19 threadId: ThreadId.make("source-thread"),
20 messageId: MessageId.make("source-message"),
21 text: "Keep the parser shared.",
22 start: 0,
23 end: 23,
24 prefix: "",
25 suffix: "",
26};
27
28describe("submitComposerDraft", () => {
29 it("keeps an oversized draft editable and sends a corrected follow-up", () => {
30 let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1);
31 let validationMessage: string | null = null;
32 const dispatchedDrafts: string[] = [];
33 const preventDefault = vi.fn();
34
35 const submit = () => {
36 const result = submitComposerDraft({
37 prompt: draft,
38 submissionTarget: "provider-turn",
39 event: { preventDefault },
40 onSend: () => {
41 dispatchedDrafts.push(draft);
42 },
43 });
44 validationMessage = result.validationMessage;
45 };
46
47 submit();
48
49 expect(dispatchedDrafts).toEqual([]);
50 expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1);
51 expect(validationMessage).toBe(
52 "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.",
53 );
54 expect(preventDefault).toHaveBeenCalledOnce();
55
56 draft = "Corrected prompt";
57 submit();
58
59 expect(dispatchedDrafts).toEqual(["Corrected prompt"]);
60 expect(validationMessage).toBeNull();
61 });
62
63 it("allows a draft at the shared character limit through the normal send path", () => {
64 const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
65 const onSend = vi.fn();
66 const preventDefault = vi.fn();
67
68 const result = submitComposerDraft({
69 prompt: draft,
70 submissionTarget: "provider-turn",
71 event: { preventDefault },
72 onSend,
73 });
74
75 expect(result).toEqual({ validationMessage: null, didDispatch: true });
76 expect(onSend).toHaveBeenCalledOnce();
77 expect(preventDefault).not.toHaveBeenCalled();
78 });
79
80 it("blocks when appended context pushes the provider input over the shared limit", () => {
81 const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
82 const onSend = vi.fn();
83
84 const result = submitComposerDraft({
85 prompt: draft,
86 providerInput: `${draft}\n\nTerminal context`,
87 submissionTarget: "provider-turn",
88 event: undefined,
89 onSend,
90 });
91
92 expect(result).toEqual({
93 validationMessage:
94 "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.",
95 didDispatch: false,
96 });
97 expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
98 expect(onSend).not.toHaveBeenCalled();
99
100 const correctedResult = submitComposerDraft({
101 prompt: "Corrected prompt",
102 providerInput: "Corrected prompt\n\nShort terminal context",
103 submissionTarget: "provider-turn",
104 event: undefined,
105 onSend,
106 });
107
108 expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true });
109 expect(onSend).toHaveBeenCalledOnce();
110 });
111
112 it("does not finish submission when the send boundary rejects composed provider input", () => {
113 const preventDefault = vi.fn();
114
115 const result = submitComposerDraft({
116 prompt: "Sendable raw draft",
117 submissionTarget: "provider-turn",
118 event: { preventDefault },
119 onSend: () => false,
120 });
121
122 expect(result).toEqual({ validationMessage: null, didDispatch: false });
123 expect(preventDefault).toHaveBeenCalledOnce();
124 });
125
126 it("allows fully composed provider input at the shared character limit", () => {
127 const onSend = vi.fn();
128
129 const result = submitComposerDraft({
130 prompt: "Short draft",
131 providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
132 submissionTarget: "provider-turn",
133 event: undefined,
134 onSend,
135 });
136
137 expect(result).toEqual({ validationMessage: null, didDispatch: true });
138 expect(onSend).toHaveBeenCalledOnce();
139 });
140
141 it.each(["draft", "composed provider input"])(
142 "blocks a %s when citation expansion exceeds the shared limit",
143 (source) => {
144 const citation = serializeAssistantCitation(assistantCitation);
145 const followUp = `Explain ${citation}\n\nTerminal context`;
146 const providerInput = `${"x".repeat(
147 PROVIDER_SEND_TURN_MAX_INPUT_CHARS -
148 expandAssistantCitationsForProvider(followUp).length +
149 1,
150 )}${followUp}`;
151 const onSend = vi.fn();
152 const preventDefault = vi.fn();
153
154 expect(providerInput.length).toBeLessThan(PROVIDER_SEND_TURN_MAX_INPUT_CHARS);
155 const result = submitComposerDraft({
156 prompt: source === "draft" ? providerInput : "Explain the quote",
157 ...(source === "composed provider input" ? { providerInput } : {}),
158 submissionTarget: "provider-turn",
159 event: { preventDefault },
160 onSend,
161 });
162
163 expect(result).toEqual({
164 validationMessage:
165 "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.",
166 didDispatch: false,
167 });
168 expect(onSend).not.toHaveBeenCalled();
169 expect(preventDefault).toHaveBeenCalledOnce();
170 },
171 );
172
173 it("keeps the encoded-message limit when citation expansion is shorter", () => {
174 const citation = serializeAssistantCitation({
175 ...assistantCitation,
176 text: "é".repeat(ASSISTANT_CITATION_MAX_TEXT_LENGTH),
177 end: ASSISTANT_CITATION_MAX_TEXT_LENGTH,
178 });
179 const draft = `${"x".repeat(
180 PROVIDER_SEND_TURN_MAX_INPUT_CHARS - citation.length + 1,
181 )}${citation}`;
182 const onSend = vi.fn();
183
184 expect(expandAssistantCitationsForProvider(draft).length).toBeLessThan(
185 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
186 );
187 const result = submitComposerDraft({
188 prompt: draft,
189 submissionTarget: "provider-turn",
190 event: undefined,
191 onSend,
192 });
193
194 expect(result).toEqual({
195 validationMessage:
196 "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.",
197 didDispatch: false,
198 });
199 expect(onSend).not.toHaveBeenCalled();
200 });
201
202 it("sends canonical citations when expanded input fits exactly after trimming", () => {
203 const citation = serializeAssistantCitation(assistantCitation);
204 const draft = ` ${"x".repeat(
205 PROVIDER_SEND_TURN_MAX_INPUT_CHARS - expandAssistantCitationsForProvider(citation).length,
206 )}${citation} \n`;
207 const dispatchedDrafts: string[] = [];
208
209 const result = submitComposerDraft({
210 prompt: draft,
211 submissionTarget: "provider-turn",
212 event: undefined,
213 onSend: () => {
214 dispatchedDrafts.push(draft);
215 },
216 });
217
218 expect(result).toEqual({ validationMessage: null, didDispatch: true });
219 expect(dispatchedDrafts).toEqual([draft]);
220 expect(dispatchedDrafts[0]).toContain(citation);
221 expect(dispatchedDrafts[0]).not.toContain("<assistant_citations>");
222 });
223
224 it("blocks a generated plan follow-up that exceeds the shared limit", () => {
225 const onSend = vi.fn();
226
227 const result = submitComposerDraft({
228 prompt: "",
229 providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat(
230 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
231 )}`,
232 submissionTarget: "provider-turn",
233 event: undefined,
234 onSend,
235 });
236
237 expect(result.didDispatch).toBe(false);
238 expect(result.validationMessage).toContain("over the 120,000-character limit");
239 expect(onSend).not.toHaveBeenCalled();
240 });
241
242 it("allows surrounding whitespace that the provider turn contract trims", () => {
243 const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `;
244 const onSend = vi.fn();
245 const preventDefault = vi.fn();
246
247 const result = submitComposerDraft({
248 prompt: draft,
249 submissionTarget: "provider-turn",
250 event: { preventDefault },
251 onSend,
252 });
253
254 expect(result).toEqual({ validationMessage: null, didDispatch: true });
255 expect(onSend).toHaveBeenCalledOnce();
256 expect(preventDefault).not.toHaveBeenCalled();
257 });
258
259 it("dispatches pending user input answers on their separate response path", () => {
260 const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1);
261 const onSend = vi.fn();
262 const preventDefault = vi.fn();
263
264 const result = submitComposerDraft({
265 prompt: answer,
266 submissionTarget: "pending-user-input",
267 event: { preventDefault },
268 onSend,
269 });
270
271 expect(result).toEqual({ validationMessage: null, didDispatch: true });
272 expect(onSend).toHaveBeenCalledOnce();
273 expect(preventDefault).not.toHaveBeenCalled();
274 });
275
276 it("does not apply citation-expanded prompt limits to pending user input answers", () => {
277 const citation = serializeAssistantCitation(assistantCitation);
278 const answer = `${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - citation.length)}${citation}`;
279 const onSend = vi.fn();
280
281 expect(expandAssistantCitationsForProvider(answer).length).toBeGreaterThan(
282 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
283 );
284 const result = submitComposerDraft({
285 prompt: answer,
286 submissionTarget: "pending-user-input",
287 event: undefined,
288 onSend,
289 });
290
291 expect(result).toEqual({ validationMessage: null, didDispatch: true });
292 expect(onSend).toHaveBeenCalledOnce();
293 });
294});