|
18 | 18 | OMITTED_TOOL_RESULT_PLACEHOLDER, |
19 | 19 | KeepLastNToolResultsCompactor, |
20 | 20 | _args_preview, |
| 21 | + default_recovery_footer, |
21 | 22 | ) |
22 | 23 |
|
23 | 24 |
|
@@ -218,3 +219,85 @@ def test_loop_cap_handle_is_used_when_no_spill_ref_is_pinned(): |
218 | 219 | ) |
219 | 220 | tool_out = next(m for m in out if m.get("role") == "tool") |
220 | 221 | assert tool_out["spill_refs"] == ["/spill/loop-cap"] |
| 222 | + |
| 223 | + |
| 224 | +# --- the recovery footer hook ---------------------------------------------- |
| 225 | + |
| 226 | + |
| 227 | +def test_the_default_footer_names_no_tool(): |
| 228 | + """A card is the whole message, so a wrong tool name here has no antidote. |
| 229 | +
|
| 230 | + This module cannot know what a host calls its recovery tool, or whether that |
| 231 | + tool is bound for the agent whose history this is. Carrying only the handle |
| 232 | + is the one rendering that is correct in every host, which is why it is the |
| 233 | + default rather than a guess at the common case. |
| 234 | + """ |
| 235 | + body = "see https://example.com/a " + "x" * 2_000 |
| 236 | + content = _blanked( |
| 237 | + _one_call("web_search", '{"query": "x"}', body), |
| 238 | + spill=lambda _n, _c: "/spill/xyz", |
| 239 | + ) |
| 240 | + assert content.splitlines()[-1] == default_recovery_footer("/spill/xyz") |
| 241 | + assert "recover" not in content.splitlines()[-1].lower() |
| 242 | + |
| 243 | + |
| 244 | +def test_a_host_footer_replaces_the_last_line_and_nothing_else(): |
| 245 | + body = "see https://example.com/a " + "x" * 2_000 |
| 246 | + messages = _one_call("web_search", '{"query": "x"}', body) |
| 247 | + default = _blanked(messages, spill=lambda _n, _c: "/spill/xyz") |
| 248 | + hosted = _blanked( |
| 249 | + messages, |
| 250 | + spill=lambda _n, _c: "/spill/xyz", |
| 251 | + recovery_footer=lambda ref: f"[Saved. Fetch it with fetch_body id {ref}.]", |
| 252 | + ) |
| 253 | + |
| 254 | + assert hosted.splitlines()[-1] == "[Saved. Fetch it with fetch_body id /spill/xyz.]" |
| 255 | + # The card above the footer — call line and source URLs — is untouched, so a |
| 256 | + # host swapping the footer cannot silently change the card's token budget. |
| 257 | + assert hosted.splitlines()[:-1] == default.splitlines()[:-1] |
| 258 | + |
| 259 | + |
| 260 | +def test_a_host_footer_still_reaches_the_model_only_when_a_body_spilled(): |
| 261 | + """No handle, no footer — a host renderer must not invent one. |
| 262 | +
|
| 263 | + The card is emitted for unspilled bodies too (that is the whole point of the |
| 264 | + args + URLs lines). Calling the footer there would have it render a pointer |
| 265 | + to nothing. |
| 266 | + """ |
| 267 | + calls: list[str] = [] |
| 268 | + |
| 269 | + def footer(ref: str) -> str: |
| 270 | + calls.append(ref) |
| 271 | + return f"[Saved: {ref}]" |
| 272 | + |
| 273 | + content = _blanked( |
| 274 | + _one_call("web_search", '{"query": "x"}', "see https://example.com/a " + "x" * 2_000), |
| 275 | + recovery_footer=footer, |
| 276 | + ) |
| 277 | + assert calls == [] |
| 278 | + assert "[Saved:" not in content |
| 279 | + |
| 280 | + |
| 281 | +def test_a_host_footer_survives_a_second_pass_unnested(): |
| 282 | + """Idempotency is anchored on the placeholder, not the footer's wording. |
| 283 | +
|
| 284 | + A host footer is free to be longer than the default, which is exactly the |
| 285 | + case where a second pass re-carding the message would compound. The |
| 286 | + already-placeheld check has to catch it regardless of what the last line says. |
| 287 | + """ |
| 288 | + body = "see https://example.com/a " + "x" * 2_000 |
| 289 | + messages = _one_call("web_search", '{"query": "x"}', body) |
| 290 | + compactor = KeepLastNToolResultsCompactor( |
| 291 | + keep_tool_result=0, |
| 292 | + spill=lambda _n, _c: "/spill/xyz", |
| 293 | + recovery_footer=lambda ref: ( |
| 294 | + "[Full text saved. Recovery id: " + ref + " — use the recover_result tool " |
| 295 | + "(a tool call, not a shell command) with that spill id.]" |
| 296 | + ), |
| 297 | + ) |
| 298 | + once = compactor.compact(messages, 0) |
| 299 | + twice = compactor.compact(once, 0) |
| 300 | + first = next(m["content"] for m in once if m.get("role") == "tool") |
| 301 | + second = next(m["content"] for m in twice if m.get("role") == "tool") |
| 302 | + assert first == second |
| 303 | + assert second.count("Recovery id:") == 1 |
0 commit comments