1import { describe, expect, it } from "vite-plus/test";
2
3import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences";
4import { buildPlanImplementationPrompt } from "../../proposedPlan";
5import {
6 buildComposerPromptHistoryEntries,
7 recallableComposerPrompt,
8 stepComposerPromptHistory,
9 type ComposerPromptHistoryPosition,
10} from "./composerPromptHistory";
11
12const entries = buildComposerPromptHistoryEntries([
13 { id: "m1", role: "user", text: "first" },
14 { id: "a1", role: "assistant", text: "reply" },
15 { id: "m2", role: "user", text: "second" },
16 { id: "m3", role: "user", text: "third" },
17]);
18
19function backward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) {
20 return stepComposerPromptHistory({ direction: "backward", entries, position, currentPrompt });
21}
22
23function forward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) {
24 return stepComposerPromptHistory({ direction: "forward", entries, position, currentPrompt });
25}
26
27describe("recallableComposerPrompt", () => {
28 it.each([
29 "Render <preview_annotation>",
30 "Render\n<preview_annotation>\nhere",
31 "Render &lt;preview_annotation&gt;",
32 ])("strips an annotation whose comment contains a literal opening tag: %s", (comment) => {
33 expect(
34 recallableComposerPrompt(
35 `Prompt\n<preview_annotation>\nComment: ${comment}\n</preview_annotation>`,
36 ),
37 ).toBe("Prompt");
38 });
39
40 it("preserves a malformed annotation containing a nested opening tag", () => {
41 const text =
42 "Prompt\n<preview_annotation>\nouter literal\n<preview_annotation>\ninner\n</preview_annotation>";
43 expect(recallableComposerPrompt(text)).toBe(text);
44 });
45 it("does not strip a terminal label embedded in ordinary prose", () => {
46 expect(
47 recallableComposerPrompt(
48 "email@build:7\n<terminal_context>\n- Build line 7:\n output\n</terminal_context>",
49 ),
50 ).toBe("email@build:7");
51 });
52 it("strips legacy send-time context blocks and the ultrathink prefix", () => {
53 const sent =
54 "Ultrathink:\nInvestigate this\n\n<terminal_context>\n- Terminal 1 lines 12-13:\n 12 | git status\n 13 | On branch main\n</terminal_context>\n\n<element_context>\n- <button>:\n url: https://example.com\n</element_context>";
55 expect(recallableComposerPrompt(sent)).toBe("Investigate this");
56 });
57
58 it.each([
59 ["Look at @terminal-1:4 please", "Look at please"],
60 [
61 "@terminal-1:4 typed twice: @terminal-1:4\n indented code",
62 "typed twice: @terminal-1:4\n indented code",
63 ],
64 [
65 "see @terminal-1:40 and @terminal-1:4-12 then @terminal-1:4",
66 "see @terminal-1:40 and @terminal-1:4-12 then",
67 ],
68 ])("removes only the matching legacy terminal label from %s", (typed, expected) => {
69 const sent =
70 typed + "\n\n<terminal_context>\n- Terminal 1 line 4:\n 4 | ls\n</terminal_context>";
71 expect(recallableComposerPrompt(sent)).toBe(expected);
72 });
73
74 it("strips only review comments appended at the end", () => {
75 const block = '<review_comment filePath="src/app.ts">Keep this configurable.</review_comment>';
76 expect(recallableComposerPrompt("Please update this.\n\n" + block)).toBe("Please update this.");
77 const midPrompt = "Before\n\n" + block + "\n\nAfter";
78 expect(recallableComposerPrompt(midPrompt)).toBe(midPrompt);
79 expect(recallableComposerPrompt(midPrompt + "\n\n" + block)).toBe(midPrompt);
80 });
81
82 it("strips a trailing preview annotation including its nested element context", () => {
83 const sent =
84 "Fix this\n\n<preview_annotation>\nPage: Example\n<element_context>\n- <button>:\n html: Save\n</element_context>\n</preview_annotation>";
85 expect(recallableComposerPrompt(sent)).toBe("Fix this");
86 });
87
88 it("removes canonical references without restoring dangling chips", () => {
89 const sent =
90 "Look at [Terminal](t3-context://v1/terminal/term-1) please\n indented code\n![image](t3-context://v1/image/img-1)";
91 expect(recallableComposerPrompt(sent)).toBe("Look at please\n indented code");
92 expect(recallableComposerPrompt("![image](t3-context://v1/image/img-1)")).toBe("");
93 });
94
95 it("returns an empty string for app-composed sends", () => {
96 expect(recallableComposerPrompt(" ")).toBe("");
97 expect(recallableComposerPrompt(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT)).toBe("");
98 expect(recallableComposerPrompt(buildPlanImplementationPrompt("# Plan\n1. do it"))).toBe("");
99 });
100});
101
102describe("buildComposerPromptHistoryEntries", () => {
103 it("keeps user messages with text, oldest first", () => {
104 expect(entries.map((entry) => entry.prompt)).toEqual(["first", "second", "third"]);
105 });
106
107 it("collapses consecutive duplicates onto the newest message id", () => {
108 const collapsed = buildComposerPromptHistoryEntries([
109 { id: "m1", role: "user", text: "same" },
110 { id: "m2", role: "user", text: "same" },
111 { id: "m3", role: "user", text: "other" },
112 { id: "m4", role: "user", text: "same" },
113 ]);
114 expect(collapsed).toEqual([
115 { id: "m2", prompt: "same" },
116 { id: "m3", prompt: "other" },
117 { id: "m4", prompt: "same" },
118 ]);
119 });
120});
121
122describe("stepComposerPromptHistory", () => {
123 it("does not start browsing from a non-empty draft", () => {
124 expect(backward(null, "typing")).toBeNull();
125 });
126
127 it("walks back from the newest entry", () => {
128 const first = backward(null, "");
129 expect(first).toEqual({ position: { entryId: "m3", recalled: "third" }, prompt: "third" });
130 expect(backward(first!.position, "third")?.prompt).toBe("second");
131 });
132
133 it("is a no-op at the oldest entry so the caret keeps moving", () => {
134 expect(backward({ entryId: "m1", recalled: "first" }, "first")).toBeNull();
135 });
136
137 it("walks forward and empties the composer past the newest entry", () => {
138 const newer = forward({ entryId: "m2", recalled: "second" }, "second");
139 expect(newer?.prompt).toBe("third");
140 expect(forward(newer!.position, "third")).toEqual({ position: null, prompt: "" });
141 });
142
143 it("treats an edited recall as a fresh draft", () => {
144 const position: ComposerPromptHistoryPosition = { entryId: "m3", recalled: "third" };
145 expect(backward(position, "third edited")).toBeNull();
146 expect(forward(position, "third edited")).toBeNull();
147 // Sent and cleared: ArrowUp starts over from the newest entry.
148 expect(backward(position, "")?.position).toEqual({ entryId: "m3", recalled: "third" });
149 });
150
151 it("does nothing on forward when not browsing", () => {
152 expect(forward(null, "")).toBeNull();
153 });
154
155 it("follows the entry by id when the list changes under it", () => {
156 const grown = buildComposerPromptHistoryEntries([
157 { id: "m0", role: "user", text: "zeroth" },
158 { id: "m1", role: "user", text: "A" },
159 { id: "m2", role: "user", text: "B" },
160 { id: "m3", role: "user", text: "A" },
161 ]);
162 const older = stepComposerPromptHistory({
163 direction: "backward",
164 entries: grown,
165 position: { entryId: "m1", recalled: "A" },
166 currentPrompt: "A",
167 });
168 expect(older?.prompt).toBe("zeroth");
169 // Unknown id with no matching text: browsing is over.
170 const missing = stepComposerPromptHistory({
171 direction: "forward",
172 entries: grown,
173 position: { entryId: "gone", recalled: "not sent" },
174 currentPrompt: "not sent",
175 });
176 expect(missing).toBeNull();
177 });
178
179 it("falls back to matching text when a duplicate collapse retires the id", () => {
180 const collapsed = buildComposerPromptHistoryEntries([
181 { id: "m1", role: "user", text: "first" },
182 { id: "m3", role: "user", text: "A" },
183 ]);
184 const step = stepComposerPromptHistory({
185 direction: "backward",
186 entries: collapsed,
187 position: { entryId: "m2", recalled: "A" },
188 currentPrompt: "A",
189 });
190 expect(step?.prompt).toBe("first");
191 });
192});