main
py 233 lines 6.82 KB
Raw
1 import sys
2 from types import SimpleNamespace
3 from pathlib import Path
4
5 import pytest
6 from langchain_core.messages import HumanMessage
7
8 PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 if str(PROJECT_ROOT) not in sys.path:
10 sys.path.insert(0, str(PROJECT_ROOT))
11
12 from plugins._chat_compaction.helpers import compactor
13
14
15 class _FakeAgent:
16 def read_prompt(self, name: str, **kwargs):
17 if name == "compact.sys.md":
18 return "system"
19 if name == "compact.msg.md":
20 return kwargs.get("conversation", "")
21 raise AssertionError(f"Unexpected prompt: {name}")
22
23
24 class _FakeLog:
25 def __init__(self):
26 self.updates = []
27 self.streams = []
28
29 def update(self, **kwargs):
30 self.updates.append(kwargs)
31
32 def stream(self, **kwargs):
33 self.streams.append(kwargs)
34
35
36 class _RecordingModel:
37 def __init__(self):
38 self.user_messages = []
39
40 async def unified_call(self, system_message, user_message, response_callback=None):
41 self.user_messages.append(user_message)
42 if response_callback:
43 await response_callback("done", "done")
44 return f"summary-{len(self.user_messages)}", None
45
46
47 class _CompactionHistory:
48 def output(self):
49 return [{"ai": False, "content": "hello"}]
50
51
52 class _CompactionLog:
53 def __init__(self):
54 self.logs = []
55 self.entries = []
56 self.reset_called = False
57 self.progress = None
58
59 def log(self, **kwargs):
60 self.entries.append(kwargs)
61 return _FakeLog()
62
63 def reset(self):
64 self.reset_called = True
65
66 def set_progress(self, *args, **kwargs):
67 self.progress = (args, kwargs)
68
69
70 class _CompactionAgent:
71 DATA_NAME_RESPONSES_STATE = "responses_state"
72
73 def __init__(self):
74 self.history = _CompactionHistory()
75 self.data = {
76 "ctx_window": {
77 "text": "pre-compaction transcript with secret values",
78 "tokens": 42,
79 },
80 "responses_state": {
81 "response_id": "resp_current",
82 "previous_response_id": "resp_previous",
83 "response_ids": ["resp_previous", "resp_current"],
84 }
85 }
86
87 def get_data(self, key):
88 return self.data.get(key)
89
90 def set_data(self, key, value):
91 self.data[key] = value
92
93
94 def test_compaction_prompt_is_resumable_task_state_without_secret_values():
95 prompt = (
96 PROJECT_ROOT / "plugins" / "_chat_compaction" / "prompts" / "compact.sys.md"
97 ).read_text(encoding="utf-8")
98 headings = [
99 "## Current objective and latest user request",
100 "## Authorized scope and prohibited actions",
101 "## Decisions and assumptions",
102 "## Completed work with evidence",
103 "## Modified files and artifacts",
104 "## Pending jobs and next executable step",
105 "## Blockers and checks not run",
106 "## Loaded skill names",
107 "## Secret references",
108 ]
109
110 positions = [prompt.index(heading) for heading in headings]
111 assert positions == sorted(positions)
112 assert "Never include passwords, API keys, tokens, credentials" in prompt
113 assert "Preserve only a secret's name, purpose, storage location" in prompt
114 assert "Keep exact values: file paths, config values, code identifiers, credentials" not in prompt
115 assert "next executable step" in prompt
116 assert "job IDs" in prompt
117
118
119 def test_pre_compaction_backup_sanitizes_surrogate_text(tmp_path, monkeypatch):
120 monkeypatch.setattr(
121 compactor,
122 "get_chat_folder_path",
123 lambda _ctxid: str(tmp_path),
124 )
125 monkeypatch.setattr(
126 compactor,
127 "export_json_chat",
128 lambda _context: '{"content":"before\ud83dafter"}',
129 )
130
131 paths = compactor._save_pre_compaction_backup(
132 SimpleNamespace(id="surrogate-chat"),
133 "transcript before\ud83dafter",
134 )
135
136 json_backup = Path(paths["json"]).read_text(encoding="utf-8")
137 text_backup = Path(paths["txt"]).read_text(encoding="utf-8")
138
139 assert "\ud83d" not in json_backup
140 assert "\ud83d" not in text_backup
141 assert "before?after" in json_backup
142 assert "before?after" in text_backup
143
144
145 def test_compaction_splitter_wraps_single_line_85k_payload(monkeypatch):
146 monkeypatch.setattr(
147 compactor.tokens, "approximate_tokens", lambda text: len(text or "")
148 )
149
150 agent = _FakeAgent()
151 chunks = compactor._split_text_for_compaction(
152 agent,
153 "x" * 85_000,
154 token_count=85_000,
155 max_input_tokens=10_000,
156 )
157
158 assert len(chunks) > 2
159 assert all(chunks)
160 assert "".join(chunks) == "x" * 85_000
161 assert all(
162 compactor._compaction_input_tokens(agent, chunk) <= 10_000
163 for chunk in chunks
164 )
165
166
167 @pytest.mark.asyncio
168 async def test_large_compaction_does_not_send_unsplit_single_line_payload(monkeypatch):
169 monkeypatch.setattr(
170 compactor.tokens, "approximate_tokens", lambda text: len(text or "")
171 )
172
173 agent = _FakeAgent()
174 model = _RecordingModel()
175
176 summary = await compactor._compact_large_history(
177 agent,
178 "x" * 85_000,
179 token_count=85_000,
180 max_input_tokens=10_000,
181 log_item=_FakeLog(),
182 model=model,
183 )
184
185 chunk_messages = model.user_messages[:-1]
186 assert summary == f"summary-{len(model.user_messages)}"
187 assert len(chunk_messages) > 2
188 assert all(chunk_messages)
189 assert all(len(message) <= 10_000 for message in chunk_messages)
190
191
192 @pytest.mark.asyncio
193 async def test_manual_compaction_preserves_summary_and_clears_responses_state(
194 monkeypatch,
195 ):
196 async def fake_single_pass(*args, **kwargs):
197 return "summary"
198
199 agent = _CompactionAgent()
200 context = SimpleNamespace(
201 id="compact-chat",
202 agent0=agent,
203 log=_CompactionLog(),
204 streaming_agent=object(),
205 )
206
207 monkeypatch.setattr(
208 compactor,
209 "_build_model",
210 lambda *args: ({"ctx_length": 128000}, _RecordingModel()),
211 )
212 monkeypatch.setattr(compactor, "_compact_single_pass", fake_single_pass)
213 monkeypatch.setattr(
214 compactor,
215 "_save_pre_compaction_backup",
216 lambda *args: {"txt": "/tmp/pre.txt"},
217 )
218 monkeypatch.setattr(compactor, "save_tmp_chat", lambda *args: None)
219 monkeypatch.setattr(compactor, "remove_msg_files", lambda *args: None)
220 monkeypatch.setattr(compactor, "mark_dirty_all", lambda *args, **kwargs: None)
221
222 await compactor.run_compaction(context)
223
224 prompt_history = agent.history.current.output_langchain()
225 assert len(prompt_history) == 1
226 assert isinstance(prompt_history[0], HumanMessage)
227 assert "## Context compacted\n\nsummary" in prompt_history[0].content
228
229 state = agent.data["responses_state"]
230 assert "response_id" not in state
231 assert "previous_response_id" not in state
232 assert state["response_ids"] == ["resp_previous", "resp_current"]
233 assert "ctx_window" not in agent.data