Skip to content

Commit a2e1f30

Browse files
author
SolonCode
committed
修复预算提示中令牌单位重复显示问题;新增全面单元测试覆盖
1 parent bc74828 commit a2e1f30

2 files changed

Lines changed: 340 additions & 2 deletions

File tree

soloncode-cli/src/main/java/org/noear/solon/codecli/command/builtin/LoopPromptBuilder.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ public String buildBudgetLimitPrompt(LoopTask task, GoalState gs) {
152152
sb.append("- 耗时: ").append(formatDuration(elapsed * 1000)).append("\n");
153153
sb.append("- 已消耗: ").append(formatTokens(gs.getConsumedTokens()));
154154
if (gs.getMaxTokens() > 0) {
155-
sb.append(" / ").append(formatTokens(gs.getMaxTokens())).append(" tokens\n");
155+
sb.append(" / ").append(formatTokens(gs.getMaxTokens())).append("\n");
156156
} else {
157-
sb.append(" tokens\n");
157+
sb.append("\n");
158158
}
159159
sb.append("\n");
160160

soloncode-cli/src/test/java/org/noear/solon/codecli/command/builtin/LoopPromptBuilderTest.java

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import org.junit.jupiter.api.Test;
1919

20+
import java.lang.reflect.Field;
21+
2022
import static org.junit.jupiter.api.Assertions.*;
2123

2224
/**
@@ -214,4 +216,340 @@ void budgetRatioReturnsCorrectValue() {
214216
gs.addTokens(2500);
215217
assertEquals(0.75, LoopPromptBuilder.budgetRatio(gs), 0.001);
216218
}
219+
220+
// ===== buildBudgetLimitPrompt — tokens 重复 bug 修复专项测试 =====
221+
222+
@Test
223+
void budgetLimitPrompt_smallTokens_noDuplication() {
224+
// consumed < 1000, maxTokens < 1000 — 最关键的 "tokens tokens" 修复场景
225+
LoopTask task = createGoalTask(500, 300, 0);
226+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
227+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
228+
assertTrue(result.contains("- 已消耗: 300 tokens / 500 tokens"),
229+
"should contain '300 tokens / 500 tokens'");
230+
}
231+
232+
@Test
233+
void budgetLimitPrompt_smallConsumedLargeMax() {
234+
// consumed < 1000, maxTokens >= 1000
235+
LoopTask task = createGoalTask(10000, 300, 0);
236+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
237+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
238+
assertTrue(result.contains("- 已消耗: 300 tokens / 10.0k"),
239+
"consumed < 1000 shows tokens suffix, max >= 1000 shows k format");
240+
}
241+
242+
@Test
243+
void budgetLimitPrompt_largeConsumedSmallMax() {
244+
// consumed >= 1000, maxTokens < 1000
245+
LoopTask task = createGoalTask(500, 2000, 0);
246+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
247+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
248+
assertTrue(result.contains("- 已消耗: 2.0k / 500 tokens"),
249+
"consumed >= 1000 shows k format, max < 1000 shows tokens suffix");
250+
}
251+
252+
@Test
253+
void budgetLimitPrompt_largeTokensBoth() {
254+
// consumed >= 1000, maxTokens >= 1000
255+
LoopTask task = createGoalTask(10000, 2000, 0);
256+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
257+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
258+
assertTrue(result.contains("- 已消耗: 2.0k / 10.0k"),
259+
"both >= 1000 should use k format");
260+
}
261+
262+
@Test
263+
void budgetLimitPrompt_unlimitedMaxTokens_smallConsumed() {
264+
// maxTokens = 0, consumed < 1000
265+
LoopTask task = createGoalTask(0, 300, 0);
266+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
267+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
268+
assertTrue(result.contains("- 已消耗: 300 tokens"),
269+
"unlimited budget, consumed < 1000 should show tokens suffix");
270+
}
271+
272+
@Test
273+
void budgetLimitPrompt_unlimitedMaxTokens_largeConsumed() {
274+
// maxTokens = 0, consumed >= 1000
275+
LoopTask task = createGoalTask(0, 2000, 0);
276+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
277+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
278+
assertTrue(result.contains("- 已消耗: 2.0k"),
279+
"unlimited budget, consumed >= 1000 should use k format");
280+
}
281+
282+
@Test
283+
void budgetLimitPrompt_zeroConsumed() {
284+
// 边界:consumed = 0
285+
LoopTask task = createGoalTask(10000, 0, 0);
286+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
287+
assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'");
288+
assertTrue(result.contains("- 已消耗: 0 tokens"),
289+
"0 tokens should still show '0 tokens'");
290+
}
291+
292+
// ===== formatTokens 单测 =====
293+
294+
@Test
295+
void formatTokens_below1000() {
296+
assertEquals("0 tokens", LoopPromptBuilder.formatTokens(0));
297+
assertEquals("1 tokens", LoopPromptBuilder.formatTokens(1));
298+
assertEquals("500 tokens", LoopPromptBuilder.formatTokens(500));
299+
assertEquals("999 tokens", LoopPromptBuilder.formatTokens(999));
300+
}
301+
302+
@Test
303+
void formatTokens_1000to1M() {
304+
assertEquals("1.0k", LoopPromptBuilder.formatTokens(1000));
305+
assertEquals("1.5k", LoopPromptBuilder.formatTokens(1500));
306+
assertEquals("10.0k", LoopPromptBuilder.formatTokens(10000));
307+
assertEquals("999.9k", LoopPromptBuilder.formatTokens(999900));
308+
}
309+
310+
@Test
311+
void formatTokens_1Mplus() {
312+
assertEquals("1.0M", LoopPromptBuilder.formatTokens(1_000_000));
313+
assertEquals("1.5M", LoopPromptBuilder.formatTokens(1_500_000));
314+
assertEquals("10.0M", LoopPromptBuilder.formatTokens(10_000_000));
315+
}
316+
317+
// ===== buildBudgetInfo — 确保无 tokens 重复 =====
318+
319+
@Test
320+
void buildBudgetInfo_noTokensDuplication() {
321+
GoalState gs = new GoalState("test", 10000);
322+
gs.addTokens(300);
323+
String info = LoopPromptBuilder.buildBudgetInfo(gs);
324+
assertFalse(info.contains("tokens tokens"), "buildBudgetInfo should not duplicate tokens");
325+
326+
// maxTokens=0 分支
327+
GoalState gs2 = new GoalState("test", 0);
328+
gs2.addTokens(300);
329+
String info2 = LoopPromptBuilder.buildBudgetInfo(gs2);
330+
assertFalse(info2.contains("tokens tokens"), "buildBudgetInfo unlimited should not duplicate tokens");
331+
}
332+
333+
// ===== buildBudgetLimitPrompt — 完整内容完整性 =====
334+
335+
@Test
336+
void budgetLimitPrompt_containsAllSections() {
337+
LoopTask task = createGoalTask(10000, 2000, 0);
338+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
339+
assertTrue(result.contains("预算耗尽 (Budget Limit)"), "should have header");
340+
assertTrue(result.contains("目标:"), "should contain goal label");
341+
assertTrue(result.contains("test goal objective here"), "should contain condition");
342+
assertTrue(result.contains("budget_limited"), "should mention budget_limited");
343+
assertTrue(result.contains("总结已完成的工作"), "should instruct summary");
344+
assertTrue(result.contains("剩余未完成的工作"), "should instruct remaining");
345+
assertTrue(result.contains("下一步建议"), "should instruct next steps");
346+
}
347+
348+
// ===== 全面覆盖:完整模式剩余分支 =====
349+
350+
@Test
351+
void fullModeWithPreviousSummary() {
352+
// 完整模式 + 非首轮 + 有上一轮结果(覆盖 "上一轮执行摘要" + truncateForPrompt 截断)
353+
LoopTask task = createGoalTask(10000, 2000, 3);
354+
String longResult = "第一轮:分析了项目结构,发现主要模块。\n" +
355+
"第二轮:实现了核心功能,包括A、B、C三个组件。\n" +
356+
"第三轮:编写了单元测试,编写了大量测试用例确保覆盖率。";
357+
// 追加大量字符触发 truncate (maxLen=300, 当前字符约120)
358+
StringBuilder sb = new StringBuilder(longResult);
359+
while (sb.length() < 350) {
360+
sb.append(" 更多内容以确保文本长度超过300字符触发截断机制。");
361+
}
362+
task.updateLastExecution(sb.toString());
363+
String result = builder.buildEffectivePrompt(task);
364+
assertTrue(result.contains("上一轮执行摘要(第 3 轮)"), "full mode should show previous round summary");
365+
assertTrue(result.contains("请基于以上进展继续推进"), "should have progress instruction");
366+
assertTrue(result.contains("...(省略)..."), "should contain ellipsis when truncated");
367+
}
368+
369+
@Test
370+
void fullModeWithoutStagnationCheck() {
371+
// 完整模式 + stagnationCount < threshold → 不应出现质疑章节
372+
LoopTask task = createGoalTask(10000, 2000, 0);
373+
task.recordStagnation(); // 1 < 3
374+
String result = builder.buildEffectivePrompt(task);
375+
assertFalse(result.contains("进展质疑 (Stagnation Check)"), "should NOT show stagnation when count < threshold");
376+
}
377+
378+
@Test
379+
void fullModeWithBudgetCritical() throws Exception {
380+
// isBudgetCritical() 在完整模式下通常不可达(ratio >= 0.30 → consumed <= 70%,
381+
// 而 critical 需要 >= 85%)。通过 GoalState.configure() 临时降低阈值覆盖此分支。
382+
Field warningField = GoalState.class.getDeclaredField("budgetWarningPercent");
383+
Field criticalField = GoalState.class.getDeclaredField("budgetCriticalPercent");
384+
warningField.setAccessible(true);
385+
criticalField.setAccessible(true);
386+
int savedWarning = warningField.getInt(null);
387+
int savedCritical = criticalField.getInt(null);
388+
389+
try {
390+
// 设置 critical 阈值为 1%,使 consumed=1000 (10%) 超过阈值
391+
GoalState.configure(savedWarning, 1);
392+
LoopTask task = createGoalTask(10000, 1000, 0);
393+
String result = builder.buildEffectivePrompt(task);
394+
assertTrue(result.contains("[紧急]"), "full mode should show '[紧急]' when budget critical");
395+
} finally {
396+
// 恢复原配置,避免影响其他测试
397+
warningField.setInt(null, savedWarning);
398+
criticalField.setInt(null, savedCritical);
399+
}
400+
}
401+
402+
@Test
403+
void fullModeContainsGoalAchievedInstruction() {
404+
LoopTask task = createGoalTask(10000, 2000, 0);
405+
String result = builder.buildEffectivePrompt(task);
406+
assertTrue(result.contains("[GOAL_ACHIEVED]"), "full mode should contain GOAL_ACHIEVED instruction");
407+
}
408+
409+
// ===== 全面覆盖:精简模式剩余分支 =====
410+
411+
@Test
412+
void compactModeFirstIter_noPreviousSummary() {
413+
// 精简模式 + isFirstIter → 不应有上一轮摘要
414+
LoopTask task = createGoalTask(10000, 8000, 0); // iter=0
415+
String result = builder.buildEffectivePrompt(task);
416+
assertFalse(result.contains("上一轮"), "compact mode first iter should NOT show previous round");
417+
}
418+
419+
@Test
420+
void compactModeNullLastResult_noPreviousSummary() {
421+
// 精简模式 + 非首轮但 lastResult=null → 不应有上一轮摘要
422+
LoopTask task = createGoalTask(10000, 8000, 2);
423+
// lastResult 显式置 null
424+
task.setLastResult(null);
425+
String result = builder.buildEffectivePrompt(task);
426+
assertFalse(result.contains("上一轮"), "compact mode with null lastResult should NOT show previous round");
427+
}
428+
429+
// ===== 全面覆盖:buildBudgetInfo =====
430+
431+
@Test
432+
void buildBudgetInfo_withBudgetWarning() {
433+
// 消耗 75% (70% ≤ 75% < 85%) → isBudgetWarning() = true
434+
GoalState gs = new GoalState("test", 10000);
435+
gs.addTokens(7500);
436+
String info = LoopPromptBuilder.buildBudgetInfo(gs);
437+
assertTrue(info.contains("[预算提示]"), "should show budget warning at 75%");
438+
assertTrue(info.contains("75%"), "should show correct percentage 75%");
439+
}
440+
441+
@Test
442+
void buildBudgetInfo_withBudgetCriticalAndRemaining() {
443+
// 消耗 90% + remainToken > 0 → 显示 (剩余: …)
444+
GoalState gs = new GoalState("test", 10000);
445+
gs.addTokens(9000);
446+
String info = LoopPromptBuilder.buildBudgetInfo(gs);
447+
assertTrue(info.contains("剩余: 1.0k"), "should show remaining tokens when critical");
448+
}
449+
450+
@Test
451+
void buildBudgetInfo_withElapsedTime() throws Exception {
452+
GoalState gs = new GoalState("test", 10000);
453+
gs.addTokens(5000);
454+
// 反射设置 startEpochMs 为 5 秒前
455+
Field field = GoalState.class.getDeclaredField("startEpochMs");
456+
field.setAccessible(true);
457+
field.setLong(gs, System.currentTimeMillis() - 5000);
458+
String info = LoopPromptBuilder.buildBudgetInfo(gs);
459+
assertTrue(info.contains("耗时:"), "should show elapsed time when > 1000ms");
460+
}
461+
462+
@Test
463+
void buildBudgetInfo_startEpochMsZero() throws Exception {
464+
// startEpochMs = 0 → 不显示耗时,其余信息正常
465+
GoalState gs = new GoalState("test", 10000);
466+
gs.addTokens(300);
467+
Field field = GoalState.class.getDeclaredField("startEpochMs");
468+
field.setAccessible(true);
469+
field.setLong(gs, 0);
470+
String info = LoopPromptBuilder.buildBudgetInfo(gs);
471+
assertFalse(info.contains("耗时:"), "should NOT show elapsed time when startEpochMs is 0");
472+
assertTrue(info.contains("已消耗 300 tokens / 10.0k"), "should still show budget info");
473+
}
474+
475+
@Test
476+
void buildBudgetInfo_maxTokensZero_consumedZero_startEpochMsZero() throws Exception {
477+
// 三个零 → 空字符串
478+
GoalState gs = new GoalState("test", 0);
479+
Field field = GoalState.class.getDeclaredField("startEpochMs");
480+
field.setAccessible(true);
481+
field.setLong(gs, 0);
482+
String info = LoopPromptBuilder.buildBudgetInfo(gs);
483+
assertEquals("", info, "should be empty when max=0, consumed=0, startEpochMs=0");
484+
}
485+
486+
// ===== 全面覆盖:budgetPercent =====
487+
488+
@Test
489+
void budgetPercent_zeroOrNegativeTotal() {
490+
assertEquals("0", LoopPromptBuilder.budgetPercent(100, 0));
491+
assertEquals("0", LoopPromptBuilder.budgetPercent(100, -1));
492+
}
493+
494+
@Test
495+
void budgetPercent_normal() {
496+
assertEquals("25", LoopPromptBuilder.budgetPercent(2500, 10000));
497+
assertEquals("0", LoopPromptBuilder.budgetPercent(0, 10000));
498+
assertEquals("100", LoopPromptBuilder.budgetPercent(10000, 10000));
499+
}
500+
501+
// ===== 全面覆盖:formatDuration =====
502+
503+
@Test
504+
void formatDuration_seconds() {
505+
assertEquals("0s", LoopPromptBuilder.formatDuration(0));
506+
assertEquals("30s", LoopPromptBuilder.formatDuration(30_000));
507+
assertEquals("59s", LoopPromptBuilder.formatDuration(59_000));
508+
}
509+
510+
@Test
511+
void formatDuration_minutes() {
512+
assertEquals("1m 0s", LoopPromptBuilder.formatDuration(60_000));
513+
assertEquals("1m 30s", LoopPromptBuilder.formatDuration(90_000));
514+
assertEquals("59m 59s", LoopPromptBuilder.formatDuration(3_599_000));
515+
}
516+
517+
@Test
518+
void formatDuration_hours() {
519+
assertEquals("1h 0m", LoopPromptBuilder.formatDuration(3_600_000));
520+
assertEquals("2h 5m", LoopPromptBuilder.formatDuration(7_500_000));
521+
assertEquals("24h 0m", LoopPromptBuilder.formatDuration(86_400_000));
522+
}
523+
524+
// ===== 全面覆盖:truncateForPrompt =====
525+
526+
@Test
527+
void truncateForPrompt_nullOrEmpty() {
528+
assertEquals("", LoopPromptBuilder.truncateForPrompt(null, 100));
529+
assertEquals("", LoopPromptBuilder.truncateForPrompt("", 100));
530+
}
531+
532+
@Test
533+
void truncateForPrompt_withinMaxLen() {
534+
assertEquals("hello", LoopPromptBuilder.truncateForPrompt("hello", 100));
535+
assertEquals("hello", LoopPromptBuilder.truncateForPrompt("hello", 5));
536+
}
537+
538+
@Test
539+
void truncateForPrompt_truncated() {
540+
String text = "hello world this is a long text for testing truncation in loop prompt builder";
541+
String result = LoopPromptBuilder.truncateForPrompt(text, 10);
542+
assertTrue(result.startsWith("hello"), "should start with first half");
543+
assertTrue(result.contains("...(省略)..."), "should contain ellipsis");
544+
assertTrue(result.endsWith("lder"), "should end with last half");
545+
}
546+
547+
// ===== 全面覆盖:buildBudgetLimitPrompt 耗时行 =====
548+
549+
@Test
550+
void budgetLimitPrompt_containsDurationLine() {
551+
LoopTask task = createGoalTask(10000, 2000, 0);
552+
String result = builder.buildBudgetLimitPrompt(task, task.getGoalState());
553+
assertTrue(result.contains("- 耗时:"), "budget limit prompt should contain duration line");
554+
}
217555
}

0 commit comments

Comments
 (0)