# -*- coding: utf-8 -*- """Tests for tools/dsh_watch.py -- the dsh background-session feedback watcher. `dsh_watch.py` is host CPython (urllib, f-free but stdlib-modern) and lives in `tools/`, so it is loaded the same way `tools/scrub.py` is in `test_scrub.py`: put `tools/` on `sys.path` and `import dsh_watch` directly. What is worth testing is the CLASSIFIER LOOP, not the HTTP. The one network call is quarantined in `_post_json`, and the loop only ever touches `_poll`, which returns a compact `{"asOfSeq","tail","perm","finish","ok"}` dict. So we monkeypatch `_poll` (occasionally `_post_json`) to script the session's state across polls, inject a fake clock and a no-op sleep, and assert the loop reaches the right verdict + exit code without a socket ever opening or a real second passing: * DONE -- tail turn/end with asOfSeq frozen, finish reason "stop" * CUT_OFF -- tail turn/end with asOfSeq frozen, finish reason "max-tokens" (reply truncated by the output cap, not finished) * STALLED -- asOfSeq frozen past --stall, tail not turn/end * ERROR -- poll raises, and poll returns ok:False * running-then-DONE -- asOfSeq advances a few polls, then turn/end freezes * TIMEOUT -- still active past --max-minutes No real dsh, no network, no sleeping. `_poll`/`_post_json` are restored by pytest's monkeypatch between tests. """ import os import sys import pytest ROOT = os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))) TOOLS = os.path.join(ROOT, 'tools') if TOOLS not in sys.path: sys.path.insert(0, TOOLS) dsh_watch = pytest.importorskip('dsh_watch') # --------------------------------------------------------------- helpers def scripted(states): """A fake `_poll` that yields one dict per call, repeating the last one forever once the script is exhausted (so a slow-to-terminate verdict never trips a StopIteration).""" box = {'i': 0} def _poll(sid, base_url=None, timeout=10.0): i = box['i'] if i < len(states): box['i'] += 1 return states[min(i, len(states) - 1)] return _poll def stepping_clock(step=100.0): """A monotonic-ish clock that advances `step` seconds every call, so a small script of polls crosses --stall / --max-minutes deterministically.""" box = {'t': 0.0} def _now(): t = box['t'] box['t'] += step return t return _now def ok(seq, tail, perm='workspace-write', finish=None): return {'ok': True, 'asOfSeq': seq, 'tail': tail, 'perm': perm, 'finish': finish} def run(sid='sess-1', **kw): kw.setdefault('interval', 0.0) kw.setdefault('sleep', lambda *_a, **_k: None) return dsh_watch.watch(sid, **kw) # --------------------------------------------------------------- DONE def test_done_when_turn_end_and_seq_frozen(monkeypatch): # Same state every poll: turn/end, seq never moves. monkeypatch.setattr(dsh_watch, '_poll', scripted([ok(42, 'turn/end')])) v = run(now=stepping_clock(1.0)) assert v.kind == 'DONE' assert v.code == 0 assert 'steps=42' in v.message assert 'turn ended' in v.message assert v.message.startswith('DONE sess-1') def test_running_then_done(monkeypatch): # seq climbs for a few polls, then turn/end appears and the seq freezes. states = [ ok(10, 'assistant/chunk'), ok(11, 'step/end'), ok(12, 'turn/end'), # the poll that first shows turn/end -- advanced ok(12, 'turn/end'), # frozen now -> DONE fires here ] monkeypatch.setattr(dsh_watch, '_poll', scripted(states)) v = run(now=stepping_clock(1.0), stall=1000.0) assert v.kind == 'DONE' assert v.code == 0 assert 'steps=12' in v.message # --------------------------------------------------------------- CUT_OFF def test_cut_off_when_turn_end_and_finish_is_max_tokens(monkeypatch): # turn/end reached with the counter frozen, but the last finish chunk says # the reply was truncated by the model's output cap: CUT_OFF, not DONE. monkeypatch.setattr(dsh_watch, '_poll', scripted([ok(42, 'turn/end', finish='max-tokens')])) v = run(now=stepping_clock(1.0)) assert v.kind == 'CUT_OFF' assert v.code == 5 assert 'max-tokens' in v.message assert '"continue"' in v.message assert v.message.startswith('CUT_OFF sess-1') def test_turn_end_with_finish_stop_is_still_done(monkeypatch): # The new finish field must not disturb the ordinary case: turn/end frozen # with a normal "stop" finish is DONE with code 0, exactly as before. monkeypatch.setattr(dsh_watch, '_poll', scripted([ok(42, 'turn/end', finish='stop')])) v = run(now=stepping_clock(1.0)) assert v.kind == 'DONE' assert v.code == 0 assert 'steps=42' in v.message assert 'turn ended' in v.message # --------------------------------------------------------------- STALLED def test_stalled_when_seq_frozen_and_not_turn_end(monkeypatch): # seq never moves and the tail is a mid-turn event: blocked on a prompt. monkeypatch.setattr(dsh_watch, '_poll', scripted([ok(7, 'assistant/chunk', perm='read-only')])) v = run(stall=60.0, now=stepping_clock(100.0)) assert v.kind == 'STALLED' assert v.code == 3 assert 'seq=7' in v.message assert 'tail=assistant/chunk' in v.message assert 'perm=read-only' in v.message assert 'pending approval/prompt' in v.message def test_turn_end_never_counts_as_stalled(monkeypatch): # A frozen seq whose tail is turn/end is DONE, never STALLED, even with a # clock that blows past --stall on the second poll. monkeypatch.setattr(dsh_watch, '_poll', scripted([ok(5, 'turn/end')])) v = run(stall=1.0, now=stepping_clock(100.0)) assert v.kind == 'DONE' # --------------------------------------------------------------- ERROR def test_error_when_poll_raises(monkeypatch): def boom(sid, base_url=None, timeout=10.0): raise RuntimeError('connection refused') monkeypatch.setattr(dsh_watch, '_poll', boom) v = run(now=stepping_clock(1.0)) assert v.kind == 'ERROR' assert v.code == 2 assert 'connection refused' in v.message def test_error_when_result_not_ok(monkeypatch): monkeypatch.setattr(dsh_watch, '_poll', scripted([{'ok': False, 'reason': 'session not found'}])) v = run(now=stepping_clock(1.0)) assert v.kind == 'ERROR' assert v.code == 2 assert 'session not found' in v.message def test_transient_hiccup_then_ok_is_not_an_error(monkeypatch): # First two polls fail, third succeeds within the same cycle's retries -> # the session is NOT declared dead; it goes on to reach DONE. states = [ {'ok': False, 'reason': 'hiccup 1'}, {'ok': False, 'reason': 'hiccup 2'}, ok(3, 'turn/end'), ok(3, 'turn/end'), ] monkeypatch.setattr(dsh_watch, '_poll', scripted(states)) v = run(now=stepping_clock(1.0)) assert v.kind == 'DONE' # --------------------------------------------------------------- TIMEOUT def test_timeout_when_still_active(monkeypatch): # seq keeps climbing every poll (genuinely busy) so DONE/STALLED never fire; # the clock crosses --max-minutes and we bail out as TIMEOUT. box = {'s': 100} def busy(sid, base_url=None, timeout=10.0): box['s'] += 1 return ok(box['s'], 'assistant/chunk') monkeypatch.setattr(dsh_watch, '_poll', busy) # step 40s/poll, cap 1 minute -> elapsed passes 60s on the second poll. v = run(max_minutes=1.0, now=stepping_clock(40.0)) assert v.kind == 'TIMEOUT' assert v.code == 4 assert 'still active' in v.message # --------------------------------------------------------------- _poll parsing def test_poll_parses_history_via_post_json(monkeypatch): # Drive _poll through a faked _post_json to prove it reads the three fields # out of the RPC envelope and never touches the (huge) events middle. captured = {} def fake_post(url, body, timeout): captured['url'] = url captured['body'] = body return {'type': 'server-response', 'rpcId': body['rpcId'], 'result': {'ok': True, 'value': { 'events': [{'event': {'type': 'assistant/chunk'}}, {'event': {'type': 'assistant/chunk', 'data': {'chunk': {'type': 'finish', 'reason': {'kind': 'stop'}}}}}, {'event': {'type': 'turn/end'}}], 'hasMore': False, 'projections': {'asOfSeq': 99, 'values': {'permissions': {'currentValue': 'workspace-write'}}}}}} monkeypatch.setattr(dsh_watch, '_post_json', fake_post) res = dsh_watch._poll('sid-xyz', base_url='http://127.0.0.1:3080') assert res == {'ok': True, 'asOfSeq': 99, 'tail': 'turn/end', 'perm': 'workspace-write', 'finish': 'stop'} assert captured['url'].endswith('/api/session.history') assert captured['body']['payload']['sessionId'] == 'sid-xyz' assert captured['body']['method'] == 'session.history' def test_poll_reports_result_ok_false(monkeypatch): def fake_post(url, body, timeout): return {'result': {'ok': False, 'error': 'no such session'}} monkeypatch.setattr(dsh_watch, '_post_json', fake_post) res = dsh_watch._poll('nope') assert res['ok'] is False assert 'no such session' in res['reason'] def test_poll_swallows_transport_exception(monkeypatch): def fake_post(url, body, timeout): raise OSError('socket blew up') monkeypatch.setattr(dsh_watch, '_post_json', fake_post) res = dsh_watch._poll('sid') assert res['ok'] is False assert 'socket blew up' in res['reason']