File size: 23,314 Bytes
0ffbb74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
"""Agent loop tests with a fake client: no API calls."""

import json
import threading
import time
from dataclasses import replace
from types import SimpleNamespace

import pytest

from beampilot.agent import (
    IMAGE_STUB,
    NUDGE,
    TOOL_DEFINITIONS,
    AgentConfig,
    Usage,
    call_cost_usd,
    run_agent,
    system_prompt,
)
from beampilot.session import MeasurementSession, clean_scenario, reveal_truth
from beampilot.tools import VERDICTS


def _call(name: str, **arguments) -> SimpleNamespace:
    _call.counter += 1
    return SimpleNamespace(
        type="function_call",
        name=name,
        call_id=f"call_{_call.counter}",
        arguments=json.dumps({"reason": "test", **arguments}),
    )


_call.counter = 0


def _response(*items, text="", tokens=(1000, 200, 100, 0)) -> SimpleNamespace:
    input_tokens, output_tokens, reasoning, cached = tokens
    _response.counter += 1
    return SimpleNamespace(
        id=f"resp_{_response.counter}",
        status="completed",
        output=list(items),
        output_text=text,
        usage=SimpleNamespace(
            input_tokens=input_tokens,
            input_tokens_details=SimpleNamespace(cached_tokens=cached),
            output_tokens=output_tokens,
            output_tokens_details=SimpleNamespace(reasoning_tokens=reasoning),
        ),
    )


_response.counter = 0


class FakeClient:
    """Returns scripted responses and records every request."""

    def __init__(self, responses):
        self._responses = list(responses)
        self.requests: list[dict] = []
        self.responses = SimpleNamespace(create=self._create)

    def _create(self, **kwargs):
        self.requests.append(kwargs)
        if not self._responses:
            raise AssertionError("the loop asked for more responses than scripted")
        response = self._responses.pop(0)
        if callable(response):
            return response()
        return response


def _session() -> MeasurementSession:
    return MeasurementSession(clean_scenario())


def test_loop_runs_tool_calls_and_stops_after_finish() -> None:
    client = FakeClient(
        [
            _response(_call("get_setup")),
            _response(
                _call("measure_at", z_mm=120.0, exposure_ms=1.5),
                _call("set_status", measurement_id="m1", status="accept"),
            ),
            _response(
                _call("finish", verdict="measurement_failed", explanation="test run")
            ),
            _response(_call("get_setup")),  # must never be requested
        ]
    )
    session = _session()
    run = run_agent(session, client)
    assert run.stop_reason == "finished"
    assert len(client.requests) == 3
    assert session.finished
    assert run.report["verdict"] == "measurement_failed"
    assert session.measurements["m1"].status == "accept"
    # The two parallel calls ran in order and were both logged.
    names = [c["name"] for c in run.steps[1]["tool_calls"]]
    assert names == ["measure_at", "set_status"]
    assert run.steps[1]["tool_calls"][0]["reason"] == "test"


def test_parallel_calls_of_one_turn_all_run_in_order_and_return() -> None:
    planned = [100.0, 110.0, 130.0, 140.0, 40.0, 200.0]
    batch = [_call("measure_at", z_mm=z, exposure_ms=1.5) for z in planned]
    statuses = [
        _call("set_status", measurement_id=f"m{i}", status="accept")
        for i in range(1, len(planned) + 1)
    ]
    client = FakeClient(
        [
            _response(*batch),
            _response(*statuses, _call("evaluate")),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    session = _session()
    run = run_agent(session, client)
    # Every call of a turn was executed, in the order given, in one model call.
    assert [m.z_m * 1e3 for m in session.measurements.values()] == pytest.approx(
        planned
    )
    assert all(m.status == "accept" for m in session.measurements.values())
    assert [len(step["tool_calls"]) for step in run.steps] == [6, 7, 1]
    assert run.steps[1]["tool_calls"][-1]["result"]["status"] == "ok"
    # Each call got its own output, matched by call_id, in the next request.
    sent = _outputs(client.requests[1])[-len(batch) :]
    assert [o["call_id"] for o in sent] == [c.call_id for c in batch]
    assert [json.loads(o["output"])["measurement_id"] for o in sent] == [
        f"m{i}" for i in range(1, 7)
    ]
    assert len(client.requests) == 3


def test_default_call_limit_is_60() -> None:
    assert AgentConfig().max_model_calls == 60


def _reasoning(encrypted="enc-abc") -> SimpleNamespace:
    return SimpleNamespace(type="reasoning", summary=[], encrypted_content=encrypted)


def _outputs(request: dict) -> list[dict]:
    return [i for i in request["input"] if i.get("type") == "function_call_output"]


def test_requests_follow_the_stateless_responses_protocol() -> None:
    first = _call("get_setup")
    client = FakeClient(
        [
            _response(_reasoning(), first),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(_session(), client, AgentConfig(reasoning_effort="medium"))
    one, two = client.requests
    instructions = system_prompt()
    assert one["instructions"] == two["instructions"] == instructions
    for request in (one, two):
        assert request["store"] is False
        assert request["include"] == ["reasoning.encrypted_content"]
        assert "previous_response_id" not in request
    assert one["reasoning"] == {"effort": "medium"}
    assert one["model"] == "muse-spark-1.2"
    # Full history: task, replayed reasoning, the call, then its output.
    task, reasoning, call, output = two["input"]
    assert task["role"] == "user"
    assert reasoning == {
        "type": "reasoning",
        "summary": [],
        "encrypted_content": "enc-abc",
    }
    assert call["type"] == "function_call" and call["call_id"] == first.call_id
    assert output["type"] == "function_call_output"
    assert output["call_id"] == first.call_id
    assert json.loads(output["output"])["wavelength_nm"] == 1064.0


def test_tool_error_goes_back_to_the_model() -> None:
    client = FakeClient(
        [
            _response(_call("measure_at", z_mm=999.0, exposure_ms=1.0)),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run = run_agent(_session(), client)
    sent = json.loads(_outputs(client.requests[1])[0]["output"])
    assert "stage range" in sent["error"]
    assert run.stop_reason == "finished"


def test_bad_arguments_and_unknown_tools_become_errors() -> None:
    broken = SimpleNamespace(
        type="function_call", name="measure_at", call_id="c1", arguments="{not json"
    )
    unknown = SimpleNamespace(
        type="function_call", name="fire_laser", call_id="c2", arguments="{}"
    )
    client = FakeClient(
        [
            _response(broken, unknown),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(_session(), client)
    outputs = [json.loads(o["output"]) for o in _outputs(client.requests[1])]
    assert "not valid JSON" in outputs[0]["error"]
    assert "unknown tool" in outputs[1]["error"]


def test_refused_finish_keeps_the_loop_going() -> None:
    client = FakeClient(
        [
            _response(
                _call("finish", verdict="iso_compliant", explanation="too early")
            ),
            _response(_call("finish", verdict="measurement_failed", explanation="ok")),
        ]
    )
    run = run_agent(_session(), client)
    assert (
        "cannot report"
        in json.loads(_outputs(client.requests[1])[0]["output"])["error"]
    )
    assert run.stop_reason == "finished"
    assert run.report["verdict"] == "measurement_failed"


def test_text_only_reply_gets_a_nudge() -> None:
    message = SimpleNamespace(
        type="message",
        content=[SimpleNamespace(type="output_text", text="I think M² is 1.2.")],
    )
    client = FakeClient(
        [
            _response(message, text="I think M² is 1.2."),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(_session(), client)
    _, replayed, nudge = client.requests[1]["input"]
    assert replayed["role"] == "assistant"
    assert replayed["content"][0]["text"] == "I think M² is 1.2."
    assert nudge == {"role": "user", "content": NUDGE}


def test_reasoning_only_turn_is_bridged_before_the_nudge() -> None:
    """The API wants an assistant message between reasoning and a user turn."""
    client = FakeClient(
        [
            _response(_reasoning()),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(_session(), client)
    _, reasoning, bridge, nudge = client.requests[1]["input"]
    assert reasoning["type"] == "reasoning"
    assert bridge["type"] == "message" and bridge["role"] == "assistant"
    assert nudge["content"] == NUDGE


def test_transient_api_error_is_retried(monkeypatch) -> None:
    monkeypatch.setattr("beampilot.agent.RETRY_DELAY_S", 0.0)

    class RateLimitError(Exception):
        pass

    def busy():
        raise RateLimitError("slow down")

    client = FakeClient(
        [
            busy,
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run = run_agent(_session(), client)
    assert run.stop_reason == "finished"
    assert len(client.requests) == 2


def test_stops_at_the_model_call_limit() -> None:
    client = FakeClient([_response(_call("get_setup")) for _ in range(10)])
    session = _session()
    run = run_agent(session, client, AgentConfig(max_model_calls=5))
    assert len(client.requests) == 5
    assert run.stop_reason == "model call limit reached (5)"
    # The controller writes a fallback report from session state.
    assert session.finished
    assert run.report["verdict"] == "measurement_failed"
    assert run.report["stopped_by_limit"] == "model call limit reached (5)"
    assert "Stopped by the controller" in run.report["explanation"]


def test_fallback_report_uses_determined_m2() -> None:
    """Stopped after enough good data: estimate_not_compliant, not failed."""
    positions = [60.0, 100.0, 120.0, 140.0, 180.0]
    calls = [
        _response(_call("measure_at", z_mm=z, exposure_ms=exposure))
        for z, exposure in zip(positions, [10.0, 1.5, 1.2, 1.5, 9.0], strict=True)
    ]
    accepts = [
        _response(_call("set_status", measurement_id=f"m{i}", status="accept"))
        for i in range(1, 6)
    ]
    client = FakeClient(calls + accepts)
    session = _session()
    run = run_agent(session, client, AgentConfig(max_model_calls=10))
    assert run.report["verdict"] == "estimate_not_compliant"
    assert run.report["evaluation"]["x"]["m2"] is not None
    assert run.report["stopped_by_limit"].startswith("model call limit")


def test_stops_at_the_token_budget() -> None:
    big = (60_000, 1_000, 500, 0)
    client = FakeClient([_response(_call("get_setup"), tokens=big) for _ in range(10)])
    run = run_agent(_session(), client, AgentConfig(max_total_tokens=150_000))
    assert len(client.requests) == 3  # 61k, 122k, 183k tokens -> stop
    assert run.stop_reason.startswith("token budget reached")


def test_stops_at_the_cost_budget() -> None:
    pricey = (100_000, 10_000, 0, 0)  # 0.125 + 0.0425 = $0.1675 per call
    client = FakeClient(
        [_response(_call("get_setup"), tokens=pricey) for _ in range(10)]
    )
    run = run_agent(_session(), client, AgentConfig(max_cost_usd=0.40))
    assert len(client.requests) == 3
    assert run.stop_reason.startswith("cost budget reached")
    assert run.cost_usd == pytest.approx(3 * 0.1675)


def test_api_error_stops_cleanly() -> None:
    def fail():
        raise ConnectionError("network down")

    client = FakeClient([fail])
    run = run_agent(_session(), client)
    # Type only: the message text could carry server text (e.g. a masked key).
    assert run.stop_reason == "API error: ConnectionError"
    assert run.steps == []
    assert run.report["verdict"] == "measurement_failed"
    assert run.report["stopped_by_limit"] == run.stop_reason


def test_hooks_fire_per_reply_and_per_tool_call_in_order() -> None:
    events = []
    client = FakeClient(
        [
            _response(_call("get_setup"), _call("evaluate"), text="looking"),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(
        _session(),
        client,
        on_model_reply=lambda step: events.append(
            ("reply", step["call"], step["text"])
        ),
        on_tool_call=lambda call: events.append(("tool", call["name"])),
        on_step=lambda step: events.append(("step", step["call"])),
    )
    assert events == [
        ("reply", 1, "looking"),
        ("tool", "get_setup"),
        ("tool", "evaluate"),
        ("step", 1),
        ("reply", 2, ""),
        ("tool", "finish"),
        ("step", 2),
    ]


def test_operator_note_reaches_the_agent_and_the_run_file() -> None:
    client = FakeClient(
        [_response(_call("finish", verdict="measurement_failed", explanation="x"))]
    )
    run = run_agent(_session(), client, operator_note="  avoid z 80-100 mm ")
    first = client.requests[0]["input"][0]["content"]
    assert first.endswith("avoid z 80-100 mm")
    assert "do not enforce it" in first
    assert run.operator_note == "avoid z 80-100 mm"
    assert run.to_dict()["operator_note"] == "avoid z 80-100 mm"
    no_note = run_agent(
        _session(),
        FakeClient(
            [_response(_call("finish", verdict="measurement_failed", explanation="x"))]
        ),
    )
    assert no_note.operator_note is None


def test_fault_injected_during_a_run_hits_the_next_measurement() -> None:
    """A demo button calls request_faults from another thread while the loop

    runs; the next measure_at sees it."""
    session = _session()

    def press_button_then_measure():
        threading.Thread(
            target=session.request_faults, kwargs={"extra_background_counts": 3.0}
        ).start()
        time.sleep(0.05)
        return _response(_call("measure_at", z_mm=120.0, exposure_ms=1.5))

    client = FakeClient(
        [
            _response(_call("measure_at", z_mm=120.0, exposure_ms=1.5)),
            press_button_then_measure,
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run = run_agent(session, client)
    first, second = (s["tool_calls"][0]["result"] for s in run.steps[:2])
    assert not first["flags"]["background_changed"]
    assert second["flags"]["background_changed"]
    assert session.fault_events[0]["source"] == "operator"


def test_api_error_text_has_type_and_status_but_no_message() -> None:
    class AuthenticationError(Exception):
        status_code = 401

    def fail():
        raise AuthenticationError("Incorrect API key provided: sk-SECRET123456")

    run = run_agent(_session(), FakeClient([fail]))
    assert run.stop_reason == "API error: AuthenticationError (HTTP 401)"
    assert "SECRET" not in json.dumps(run.to_dict())


def test_should_stop_cancels_before_the_next_model_call() -> None:
    calls = {"n": 0}

    def stop_after_two() -> bool:
        calls["n"] += 1
        return calls["n"] > 2

    client = FakeClient([_response(_call("get_setup")) for _ in range(5)])
    run = run_agent(_session(), client, should_stop=stop_after_two)
    assert len(client.requests) == 2
    assert run.stop_reason == "cancelled by the controller"
    assert run.report["stopped_by_limit"] == "cancelled by the controller"


def test_view_frame_image_reaches_the_model_but_not_the_log() -> None:
    client = FakeClient(
        [
            _response(_call("measure_at", z_mm=120.0, exposure_ms=1.5)),
            _response(_call("view_frame", measurement_id="m1")),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    session = _session()
    run = run_agent(session, client)
    output = _outputs(client.requests[2])[-1]["output"]
    assert isinstance(output, list)
    text, image = output
    assert json.loads(text["text"])["image_id"] == "view1-m1"
    assert image["type"] == "input_image"
    assert image["image_url"].startswith("data:image/png;base64,")
    assert session.images == {}  # handed over once, not kept
    logged = json.dumps(run.to_dict()) + json.dumps(session.log)
    assert "base64" not in logged


def test_requests_never_contain_the_hidden_truth() -> None:
    client = FakeClient(
        [
            _response(_call("get_setup")),
            _response(_call("measure_at", z_mm=120.0, exposure_ms=1.5)),
            _response(_call("evaluate")),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    session = MeasurementSession(replace(clean_scenario(), name="secret-name-7f3a"))
    run_agent(session, client)
    sent = json.dumps(client.requests, default=str, ensure_ascii=False)
    # Measured values are legitimately close to the truth, so check that the
    # scenario identity and the ground-truth fields never leave the session.
    assert "secret-name-7f3a" not in sent
    for key in reveal_truth(session):
        if key not in ("scenario", "wavelength_nm"):  # wavelength is known setup
            assert key not in sent, key


def test_calls_after_a_successful_finish_are_refused() -> None:
    client = FakeClient(
        [
            _response(
                _call("finish", verdict="measurement_failed", explanation="x"),
                _call("get_setup"),
            ),
        ]
    )
    run = run_agent(_session(), client)
    assert run.stop_reason == "finished"
    assert "finished" in run.steps[0]["tool_calls"][1]["result"]["error"]


def test_tool_definitions_are_strict_with_a_required_reason() -> None:
    names = {tool["name"] for tool in TOOL_DEFINITIONS}
    assert names == {
        "get_setup",
        "measure_at",
        "retake_darks",
        "view_frame",
        "set_status",
        "evaluate",
        "finish",
    }
    for tool in TOOL_DEFINITIONS:
        parameters = tool["parameters"]
        assert tool["type"] == "function" and tool["strict"] is True
        assert parameters["additionalProperties"] is False
        assert "reason" in parameters["required"]
        assert set(parameters["required"]) == set(parameters["properties"])
    finish = next(t for t in TOOL_DEFINITIONS if t["name"] == "finish")
    assert finish["parameters"]["properties"]["verdict"]["enum"] == list(VERDICTS)


def test_cost_uses_cached_and_contributor_prices() -> None:
    usage = Usage(input_tokens=1_000_000, cached_tokens=500_000, output_tokens=100_000)
    standard = 0.5 * 1.25 + 0.5 * 0.15 + 0.1 * 4.25
    contributor = 0.5 * 0.10 + 0.5 * 0.002 + 0.1 * 0.20
    assert call_cost_usd(usage, "muse-spark-1.2") == pytest.approx(standard)
    assert call_cost_usd(usage, "muse-spark-1.2-contributor") == pytest.approx(
        contributor
    )


def test_system_prompt_is_short_and_complete() -> None:
    prompt = system_prompt()
    assert len(prompt.split()) < 1500
    for word in ("ISO 11146-1", "reason", "retake_darks", "view_frame", *VERDICTS):
        assert word in prompt


def test_only_the_two_latest_images_are_resent() -> None:
    client = FakeClient(
        [
            _response(
                _call("measure_at", z_mm=100.0, exposure_ms=1.5),
                _call("measure_at", z_mm=120.0, exposure_ms=1.5),
                _call("measure_at", z_mm=140.0, exposure_ms=1.5),
            ),
            _response(_call("view_frame", measurement_id="m1")),
            _response(_call("view_frame", measurement_id="m2")),
            _response(_call("view_frame", measurement_id="m3")),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(_session(), client)
    last = _outputs(client.requests[-1])
    images = [o for o in last if isinstance(o["output"], list)]
    stubs = [
        o for o in last if isinstance(o["output"], str) and IMAGE_STUB in o["output"]
    ]
    assert len(images) == 2
    assert len(stubs) == 1 and '"view1-m1"' in stubs[0]["output"]
    # Earlier requests keep what was actually sent then.
    assert sum(isinstance(o["output"], list) for o in _outputs(client.requests[3])) == 2


def test_images_of_one_batched_turn_all_reach_the_model() -> None:
    client = FakeClient(
        [
            _response(
                _call("measure_at", z_mm=100.0, exposure_ms=1.5),
                _call("measure_at", z_mm=120.0, exposure_ms=1.5),
                _call("measure_at", z_mm=140.0, exposure_ms=1.5),
            ),
            _response(
                _call("view_frame", measurement_id="m1"),
                _call("view_frame", measurement_id="m2"),
                _call("view_frame", measurement_id="m3"),
            ),
            _response(_call("get_setup")),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    run_agent(_session(), client)
    # All three images of the batched turn are sent once ...
    assert sum(isinstance(o["output"], list) for o in _outputs(client.requests[2])) == 3
    # ... and only later the oldest becomes a stub.
    assert sum(isinstance(o["output"], list) for o in _outputs(client.requests[3])) == 2


def test_view_frame_can_be_switched_off_for_the_ablation() -> None:
    client = FakeClient(
        [
            _response(
                _call("get_setup"), _call("measure_at", z_mm=120.0, exposure_ms=1.5)
            ),
            _response(_call("view_frame", measurement_id="m1")),
            _response(_call("finish", verdict="measurement_failed", explanation="x")),
        ]
    )
    session = _session()
    run = run_agent(session, client, AgentConfig(view_frame=False))
    request = client.requests[0]
    assert "view_frame" not in {t["name"] for t in request["tools"]}
    assert "view_frame" not in request["instructions"]
    assert "# How to work" in request["instructions"]
    assert "views_left" not in run.steps[0]["tool_calls"][0]["result"]
    refused = run.steps[1]["tool_calls"][0]["result"]
    offered = refused["error"].split("tools: ")[1].split(", ")
    assert "unknown tool 'view_frame'" in refused["error"]
    assert "view_frame" not in offered and "measure_at" in offered
    assert session.views_used == 0
    assert "Looking at frames" in system_prompt()