diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 09e6d19f..a50e8753 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -62,12 +62,21 @@ public class ConversationWindowManager { /** 迭代更新:合并旧摘要 + 新轮次 */ private static final String STRUCTURED_SUMMARY_UPDATE = PromptLoader.loadPrompt("context/structured-summary-update"); - /** 摘要注入前缀 */ - private static final String SUMMARY_PREFIX = + /** 摘要注入前缀 (package-private for test assertions) */ + static final String SUMMARY_PREFIX = "[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" + "以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" + "请基于摘要和当前状态继续,避免重复已完成的工作:\n\n"; + /** + * Marker prefix used by the first-user anchor. Lets compaction skip + * previously-injected anchors when looking for the "real" first user + * message in a subsequent round. + * + *
Package-private so unit tests can assert on the marker.
+ */
+ static final String ANCHOR_PREFIX = "[Original goal]\n";
+
// ==================== 序列化截断参数 ====================
private static final int CONTENT_MAX = 6000;
@@ -363,6 +372,15 @@ public class ConversationWindowManager {
List Sizing rules:
+ * Always returns a {@link UserMessage}. {@code null} when anchoring
+ * is disabled, no real first user exists in the prefix, or the body is
+ * blank.
+ *
+ * Package-private for direct unit testing — the surrounding
+ * {@link #compactMessages} path needs a ChatModel and the whole
+ * structured-summary pipeline, which the anchor logic does not.
+ */
+ Message buildFirstUserAnchor(List Invariants verified here:
+ *
+ *
+ *
+ *
+ *
+ */
+class ConversationWindowManagerAnchorTest {
+
+ @Test
+ void shortFirstUserStaysVerbatim() {
+ ConversationWindowManager mgr = newManager(true, 400);
+
+ String goal = "find the bug in foo.js";
+ Message anchor = mgr.buildFirstUserAnchor(List.of(
+ new UserMessage(goal),
+ new AssistantMessage("looking into it")
+ ));
+
+ assertInstanceOf(UserMessage.class, anchor);
+ String text = anchor.getText();
+ assertTrue(text.startsWith(ConversationWindowManager.ANCHOR_PREFIX));
+ assertTrue(text.contains(goal),
+ "short goals fit the budget verbatim, no truncation marker should appear");
+ }
+
+ @Test
+ void anchorIsAlwaysUserMessageNeverSystem() {
+ ConversationWindowManager mgr = newManager(true, 400);
+
+ Message anchor = mgr.buildFirstUserAnchor(List.of(
+ new UserMessage("rewrite this README")
+ ));
+
+ // Critical safety property: never promote historical user input into a SystemMessage.
+ assertInstanceOf(UserMessage.class, anchor);
+ }
+
+ @Test
+ void disabledAnchorReturnsNull() {
+ ConversationWindowManager mgr = newManager(false, 400);
+
+ Message anchor = mgr.buildFirstUserAnchor(List.of(
+ new UserMessage("anything")
+ ));
+
+ assertNull(anchor);
+ }
+
+ @Test
+ void noUserInPrefixReturnsNull() {
+ ConversationWindowManager mgr = newManager(true, 400);
+
+ // Prefix is all assistant messages — no user goal to anchor.
+ Message anchor = mgr.buildFirstUserAnchor(List.of(
+ new AssistantMessage("blah"),
+ new AssistantMessage("more blah")
+ ));
+
+ assertNull(anchor);
+ }
+
+ @Test
+ void previousSummaryAndPriorAnchorAreSkipped() {
+ ConversationWindowManager mgr = newManager(true, 400);
+
+ String realGoal = "ship a feature flag for the new pricing page";
+ Message anchor = mgr.buildFirstUserAnchor(List.of(
+ // round-2 prefix: starts with a previous summary, then a prior anchor,
+ // then the actual original user message.
+ new UserMessage(ConversationWindowManager.SUMMARY_PREFIX + "earlier summary text"),
+ new UserMessage(ConversationWindowManager.ANCHOR_PREFIX + "stale anchor from prior round"),
+ new UserMessage(realGoal),
+ new AssistantMessage("on it")
+ ));
+
+ assertNotNull(anchor);
+ assertTrue(anchor.getText().contains(realGoal),
+ "anchor must reflect the REAL first user message, not a prior summary or prior anchor");
+ }
+
+ @Test
+ void mediumOverBudgetIsHeadTailTruncated() {
+ // 80-token budget → roughly 160-char head+tail target.
+ ConversationWindowManager mgr = newManager(true, 80);
+
+ // ~400 chars — within 3× the budget so head+tail truncation should apply.
+ String body = "a".repeat(200) + "MIDDLE" + "b".repeat(200);
+ Message anchor = mgr.buildFirstUserAnchor(List.of(new UserMessage(body)));
+
+ assertNotNull(anchor);
+ String text = anchor.getText();
+ assertTrue(text.contains("...["),
+ "head+tail truncation marker should be present");
+ assertTrue(text.length() < body.length(),
+ "anchor must be smaller than original (was " + text.length() + " vs " + body.length() + ")");
+ // Head and tail of the original body must both be present.
+ assertTrue(text.startsWith(ConversationWindowManager.ANCHOR_PREFIX));
+ // The first run of 'a's should still be there
+ assertTrue(text.contains("aaaaaaaaaa"));
+ // And the tail run of 'b's
+ assertTrue(text.contains("bbbbbbbbbb"));
+ }
+
+ @Test
+ void hugeBodyDegradesToPointerLine() {
+ ConversationWindowManager mgr = newManager(true, 80);
+
+ // > 3× the budget → pointer-only path.
+ String body = "X".repeat(5000);
+ Message anchor = mgr.buildFirstUserAnchor(List.of(new UserMessage(body)));
+
+ assertNotNull(anchor);
+ String text = anchor.getText();
+ assertTrue(text.length() < 500,
+ "pointer line should be far smaller than the body (was " + text.length() + ")");
+ assertTrue(text.endsWith("..."),
+ "pointer line should end with the truncation marker");
+ }
+
+ @Test
+ void blankUserMessageReturnsNull() {
+ ConversationWindowManager mgr = newManager(true, 400);
+
+ Message anchor = mgr.buildFirstUserAnchor(List.of(
+ new UserMessage(""),
+ new AssistantMessage("ack")
+ ));
+
+ // No real goal text — nothing to anchor.
+ assertNull(anchor);
+ }
+
+ @Test
+ void anchorPrefixIsConsistent() {
+ ConversationWindowManager mgr = newManager(true, 400);
+
+ Message a = mgr.buildFirstUserAnchor(List.of(new UserMessage("short")));
+ Message b = mgr.buildFirstUserAnchor(List.of(new UserMessage("a different short goal")));
+
+ // Stable marker — downstream code (and the dedup in buildFirstUserAnchor itself)
+ // depends on this prefix being constant.
+ assertEquals(ConversationWindowManager.ANCHOR_PREFIX,
+ a.getText().substring(0, ConversationWindowManager.ANCHOR_PREFIX.length()));
+ assertEquals(ConversationWindowManager.ANCHOR_PREFIX,
+ b.getText().substring(0, ConversationWindowManager.ANCHOR_PREFIX.length()));
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ private static ConversationWindowManager newManager(boolean enabled, int maxAnchorTokens) {
+ ConversationWindowProperties props = new ConversationWindowProperties();
+ props.setFirstUserAnchorEnabled(enabled);
+ props.setFirstUserAnchorMaxTokens(maxAnchorTokens);
+ return new ConversationWindowManager(props, null, null);
+ }
+}