Skip to content

Commit ce49176

Browse files
authored
add unit tests for all exported functions in thinking-recovery
test(thinking-recovery): add unit tests for all exported functions
2 parents 09ccf4b + 5b76820 commit ce49176

1 file changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
analyzeConversationState,
4+
closeToolLoopForThinking,
5+
hasPossibleCompactedThinking,
6+
looksLikeCompactedThinkingTurn,
7+
needsThinkingRecovery,
8+
} from "./thinking-recovery";
9+
10+
// ─── Fixtures ────────────────────────────────────────────────────────────────
11+
12+
function userMsg(text: string) {
13+
return { role: "user", parts: [{ text }] };
14+
}
15+
16+
function modelMsg(text: string) {
17+
return { role: "model", parts: [{ text }] };
18+
}
19+
20+
function modelWithThinking(text: string) {
21+
return {
22+
role: "model",
23+
parts: [{ thought: true, text: "thinking..." }, { text }],
24+
};
25+
}
26+
27+
function modelWithToolCall(name = "myTool") {
28+
return {
29+
role: "model",
30+
parts: [{ functionCall: { name, args: {} } }],
31+
};
32+
}
33+
34+
function modelWithThinkingAndToolCall(name = "myTool") {
35+
return {
36+
role: "model",
37+
parts: [
38+
{ thought: true, text: "reasoning..." },
39+
{ functionCall: { name, args: {} } },
40+
],
41+
};
42+
}
43+
44+
function toolResultMsg(name = "myTool") {
45+
return {
46+
role: "user",
47+
parts: [{ functionResponse: { name, response: { result: "ok" } } }],
48+
};
49+
}
50+
51+
// ─── analyzeConversationState ─────────────────────────────────────────────────
52+
53+
describe("analyzeConversationState", () => {
54+
it("returns default state for empty contents", () => {
55+
const state = analyzeConversationState([]);
56+
expect(state.inToolLoop).toBe(false);
57+
expect(state.turnStartIdx).toBe(-1);
58+
expect(state.lastModelIdx).toBe(-1);
59+
});
60+
61+
it("returns default state for non-array input", () => {
62+
const state = analyzeConversationState(null as any);
63+
expect(state.inToolLoop).toBe(false);
64+
});
65+
66+
it("detects a simple user→model conversation (not in tool loop)", () => {
67+
const contents = [userMsg("hello"), modelMsg("hi there")];
68+
const state = analyzeConversationState(contents);
69+
expect(state.inToolLoop).toBe(false);
70+
expect(state.lastModelIdx).toBe(1);
71+
expect(state.lastModelHasThinking).toBe(false);
72+
expect(state.lastModelHasToolCalls).toBe(false);
73+
});
74+
75+
it("detects thinking in last model message", () => {
76+
const contents = [userMsg("hello"), modelWithThinking("hi there")];
77+
const state = analyzeConversationState(contents);
78+
expect(state.lastModelHasThinking).toBe(true);
79+
expect(state.turnHasThinking).toBe(true);
80+
});
81+
82+
it("detects tool loop: conversation ends with tool result", () => {
83+
const contents = [
84+
userMsg("do something"),
85+
modelWithToolCall("search"),
86+
toolResultMsg("search"),
87+
];
88+
const state = analyzeConversationState(contents);
89+
expect(state.inToolLoop).toBe(true);
90+
expect(state.lastModelIdx).toBe(1);
91+
expect(state.lastModelHasToolCalls).toBe(true);
92+
});
93+
94+
it("detects tool loop with multiple tool results", () => {
95+
const contents = [
96+
userMsg("do two things"),
97+
{ role: "model", parts: [
98+
{ functionCall: { name: "a", args: {} } },
99+
{ functionCall: { name: "b", args: {} } },
100+
]},
101+
{ role: "user", parts: [
102+
{ functionResponse: { name: "a", response: {} } },
103+
{ functionResponse: { name: "b", response: {} } },
104+
]},
105+
];
106+
const state = analyzeConversationState(contents);
107+
expect(state.inToolLoop).toBe(true);
108+
});
109+
110+
it("is NOT in tool loop when last message is a real user message", () => {
111+
const contents = [
112+
userMsg("task"),
113+
modelWithToolCall(),
114+
toolResultMsg(),
115+
modelMsg("done"),
116+
userMsg("thanks"),
117+
];
118+
const state = analyzeConversationState(contents);
119+
expect(state.inToolLoop).toBe(false);
120+
});
121+
122+
it("tracks turn start correctly across multi-step tool loop", () => {
123+
const contents = [
124+
userMsg("first real user"),
125+
modelWithThinkingAndToolCall("step1"),
126+
toolResultMsg("step1"),
127+
modelWithToolCall("step2"),
128+
toolResultMsg("step2"),
129+
];
130+
const state = analyzeConversationState(contents);
131+
expect(state.turnStartIdx).toBe(1); // first model message in turn
132+
expect(state.turnHasThinking).toBe(true);
133+
expect(state.inToolLoop).toBe(true);
134+
});
135+
136+
it("turns NOT having thinking when first model msg has no thinking", () => {
137+
const contents = [
138+
userMsg("go"),
139+
modelWithToolCall("t1"),
140+
toolResultMsg("t1"),
141+
];
142+
const state = analyzeConversationState(contents);
143+
expect(state.turnHasThinking).toBe(false);
144+
expect(state.inToolLoop).toBe(true);
145+
});
146+
});
147+
148+
// ─── needsThinkingRecovery ────────────────────────────────────────────────────
149+
150+
describe("needsThinkingRecovery", () => {
151+
it("returns false when not in tool loop", () => {
152+
expect(needsThinkingRecovery({ inToolLoop: false, turnHasThinking: false,
153+
turnStartIdx: -1, lastModelIdx: -1, lastModelHasThinking: false,
154+
lastModelHasToolCalls: false })).toBe(false);
155+
});
156+
157+
it("returns false when in tool loop but turn had thinking", () => {
158+
expect(needsThinkingRecovery({ inToolLoop: true, turnHasThinking: true,
159+
turnStartIdx: 1, lastModelIdx: 2, lastModelHasThinking: false,
160+
lastModelHasToolCalls: true })).toBe(false);
161+
});
162+
163+
it("returns true when in tool loop without thinking", () => {
164+
expect(needsThinkingRecovery({ inToolLoop: true, turnHasThinking: false,
165+
turnStartIdx: 1, lastModelIdx: 2, lastModelHasThinking: false,
166+
lastModelHasToolCalls: true })).toBe(true);
167+
});
168+
});
169+
170+
// ─── closeToolLoopForThinking ─────────────────────────────────────────────────
171+
172+
describe("closeToolLoopForThinking", () => {
173+
it("appends synthetic model + user messages", () => {
174+
const contents = [
175+
userMsg("go"),
176+
modelWithToolCall("search"),
177+
toolResultMsg("search"),
178+
];
179+
const result = closeToolLoopForThinking(contents);
180+
expect(result.length).toBe(5);
181+
expect(result[3]?.role).toBe("model");
182+
expect(result[4]?.role).toBe("user");
183+
expect(result[4]?.parts[0]?.text).toBe("[Continue]");
184+
});
185+
186+
it("strips thinking blocks from prior messages", () => {
187+
const contents = [
188+
userMsg("hello"),
189+
modelWithThinking("response"),
190+
toolResultMsg(),
191+
];
192+
const result = closeToolLoopForThinking(contents);
193+
const modelMessages = result.filter((m) => m.role === "model");
194+
for (const msg of modelMessages) {
195+
const parts: any[] = msg.parts ?? [];
196+
const hasThinking = parts.some((p: any) => p?.thought === true);
197+
expect(hasThinking).toBe(false);
198+
}
199+
});
200+
201+
it("uses singular message for single tool result", () => {
202+
const contents = [userMsg("go"), modelWithToolCall(), toolResultMsg()];
203+
const result = closeToolLoopForThinking(contents);
204+
const syntheticModel = result[result.length - 2];
205+
expect(syntheticModel?.parts[0]?.text).toBe("[Tool execution completed.]");
206+
});
207+
208+
it("uses plural message for multiple tool results", () => {
209+
const contents = [
210+
userMsg("go"),
211+
{ role: "model", parts: [
212+
{ functionCall: { name: "a", args: {} } },
213+
{ functionCall: { name: "b", args: {} } },
214+
]},
215+
{ role: "user", parts: [
216+
{ functionResponse: { name: "a", response: {} } },
217+
{ functionResponse: { name: "b", response: {} } },
218+
]},
219+
];
220+
const result = closeToolLoopForThinking(contents);
221+
const syntheticModel = result[result.length - 2];
222+
expect(syntheticModel?.parts[0]?.text).toBe("[2 tool executions completed.]");
223+
});
224+
225+
it("uses fallback message when no tool results present", () => {
226+
const contents = [userMsg("go"), modelMsg("working...")];
227+
const result = closeToolLoopForThinking(contents);
228+
const syntheticModel = result[result.length - 2];
229+
expect(syntheticModel?.parts[0]?.text).toBe("[Processing previous context.]");
230+
});
231+
232+
it("does not mutate original contents array", () => {
233+
const contents = [userMsg("go"), modelWithToolCall(), toolResultMsg()];
234+
const original = JSON.stringify(contents);
235+
closeToolLoopForThinking(contents);
236+
expect(JSON.stringify(contents)).toBe(original);
237+
});
238+
});
239+
240+
// ─── looksLikeCompactedThinkingTurn ──────────────────────────────────────────
241+
242+
describe("looksLikeCompactedThinkingTurn", () => {
243+
it("returns false for null / undefined", () => {
244+
expect(looksLikeCompactedThinkingTurn(null)).toBe(false);
245+
expect(looksLikeCompactedThinkingTurn(undefined)).toBe(false);
246+
});
247+
248+
it("returns false for message with no parts", () => {
249+
expect(looksLikeCompactedThinkingTurn({ role: "model", parts: [] })).toBe(false);
250+
});
251+
252+
it("returns false for message without function calls", () => {
253+
expect(looksLikeCompactedThinkingTurn(modelMsg("just text"))).toBe(false);
254+
});
255+
256+
it("returns false when message has thinking blocks alongside function call", () => {
257+
const msg = {
258+
role: "model",
259+
parts: [
260+
{ thought: true, text: "thinking" },
261+
{ functionCall: { name: "t", args: {} } },
262+
],
263+
};
264+
expect(looksLikeCompactedThinkingTurn(msg)).toBe(false);
265+
});
266+
267+
it("returns false when text appears before function call (non-compacted)", () => {
268+
const msg = {
269+
role: "model",
270+
parts: [
271+
{ text: "I will now call the tool." },
272+
{ functionCall: { name: "t", args: {} } },
273+
],
274+
};
275+
expect(looksLikeCompactedThinkingTurn(msg)).toBe(false);
276+
});
277+
278+
it("returns true for bare function call with no preceding text (looks compacted)", () => {
279+
const msg = modelWithToolCall("search");
280+
expect(looksLikeCompactedThinkingTurn(msg)).toBe(true);
281+
});
282+
});
283+
284+
// ─── hasPossibleCompactedThinking ────────────────────────────────────────────
285+
286+
describe("hasPossibleCompactedThinking", () => {
287+
it("returns false for empty contents", () => {
288+
expect(hasPossibleCompactedThinking([], 0)).toBe(false);
289+
});
290+
291+
it("returns false for invalid turnStartIdx", () => {
292+
expect(hasPossibleCompactedThinking([modelMsg("hi")], -1)).toBe(false);
293+
});
294+
295+
it("returns false when no model messages look compacted", () => {
296+
const contents = [userMsg("go"), modelWithThinkingAndToolCall(), toolResultMsg()];
297+
expect(hasPossibleCompactedThinking(contents, 1)).toBe(false);
298+
});
299+
300+
it("returns true when a model message in turn looks compacted", () => {
301+
const contents = [
302+
userMsg("go"),
303+
modelWithToolCall("search"),
304+
toolResultMsg("search"),
305+
];
306+
expect(hasPossibleCompactedThinking(contents, 1)).toBe(true);
307+
});
308+
309+
it("ignores model messages before turnStartIdx", () => {
310+
const contents = [
311+
userMsg("first turn"),
312+
modelWithToolCall("old"),
313+
toolResultMsg("old"),
314+
userMsg("second turn"),
315+
modelWithThinkingAndToolCall("new"),
316+
toolResultMsg("new"),
317+
];
318+
// turnStart is 4 (second model message)
319+
expect(hasPossibleCompactedThinking(contents, 4)).toBe(false);
320+
});
321+
});

0 commit comments

Comments
 (0)