8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
261 lines
9.8 KiB
Python
261 lines
9.8 KiB
Python
# -*- 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']
|