"""Decision lifecycle contract tests use isolated stores and HTTP servers only."""
import concurrent.futures
import hashlib
import json
import sqlite3
from pathlib import Path
import tempfile
import unittest
from agent_control.store import Store, Invalid, Conflict
import test_decisions as fixtures
from test_decisions import definition, answer
THREAD='00000000-0000-4000-8000-000000000001'
def review(**changes):
data=dict(expected_revision=1,idempotency_key='review',outcome='accept',reason='Checked facts',impact='Limited scope',execution_plan='Owner verifies execution separately')
data.update(changes)
return data
def completion(**changes):
data=dict(expected_revision=1,idempotency_key='complete',conclusion='Reviewed outcome',handoff='Owner retains implementation task',evidence=['Synthetic verification'])
data.update(changes)
return data
def bind(store,did='choice',sid='consult',thread=THREAD):
store.session(dict(id=sid,name='Consultation',role='decision',wp=did,thread_id=thread,worktree='/synthetic'))
return store.decision_bind(did,dict(expected_revision=1,idempotency_key='bind',session_id=sid,thread_id=thread))
class WorkflowTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory()
self.path=Path(self.tmp.name)/'state.sqlite3'
self.store=Store(self.path)
self.store.decision(definition())
def tearDown(self):
self.tmp.cleanup()
def test_accept_complete_archive_requires_review_and_real_receipt(self):
bind(self.store)
with self.assertRaises(Conflict): self.store.decision_complete('choice',completion())
with self.assertRaises(Conflict): self.store.decision_review('choice',review())
self.store.decision_respond('choice',answer())
accepted=self.store.decision_review('choice',review())
self.assertEqual(accepted['status'],'accepted')
self.assertFalse(accepted['response']['authorization'])
self.assertEqual(accepted,self.store.decision_review('choice',review()))
completed=self.store.decision_complete('choice',completion())
self.assertEqual((completed['status'],completed['archive_status']),('completed','pending'))
receipt=dict(thread_id=THREAD,idempotency_key='archive',receipt='Host tool receipt synthetic')
with self.assertRaises(Conflict): self.store.decision_archive('choice',dict(receipt,thread_id='other'))
archived=self.store.decision_archive('choice',receipt)
self.assertEqual(archived['archive_status'],'archived')
self.assertEqual(archived,Store(self.path).decision_archive('choice',receipt))
with self.assertRaises(Conflict): self.store.decision_archive('choice',dict(receipt,receipt='changed'))
kinds=[x['kind'] for x in self.store.decision_history('choice')['history']]
self.assertEqual(kinds,['version','binding','submission','review','completion','archive'])
def test_reconsider_and_free_text_with_options(self):
bind(self.store)
submitted=self.store.decision_respond('choice',answer(option_id=None,comment='Alternative approach'))
self.assertEqual(submitted['status'],'pending_review')
reconsidered=self.store.decision_review('choice',review(outcome='reconsider'))
self.assertEqual(reconsidered['status'],'needs_reconsideration')
self.assertEqual(self.store.decision_complete('choice',completion())['status'],'completed')
with self.assertRaises(Conflict): self.store.decision_respond('choice',answer(idempotency_key='again'))
def test_complete_without_bound_session_cannot_request_archive(self):
self.store.decision_respond('choice',answer())
self.store.decision_review('choice',review())
with self.assertRaises(Conflict): self.store.decision_complete('choice',completion())
self.assertEqual(self.store.decision_get('choice')['status'],'accepted')
self.assertFalse(any(n['event']['kind']=='archive_required' for n in self.store.decision_notifications()['notifications']))
def test_versions_preserve_submission_and_original_create_definition(self):
submitted=self.store.decision_respond('choice',answer())
value=self.store.decision_patch('choice',dict(expected_revision=1,reason='New facts',options=[dict(id='b',label='B',description='Different')]))
self.assertEqual((value['revision'],value['status']),(2,'needs_reconsideration'))
self.assertEqual(value['response'],submitted['response'])
self.assertEqual(value['response']['snapshot']['options'][0]['id'],'a')
self.assertEqual(self.store.decision(definition()),value)
with self.assertRaises(Conflict): self.store.decision_review('choice',review(expected_revision=2))
with self.assertRaises(Conflict): self.store.decision_respond('choice',answer(idempotency_key='new'))
with self.assertRaises(Invalid): self.store.decision_respond('choice',answer(expected_revision=2,idempotency_key='new'))
fresh=self.store.decision_respond('choice',answer(expected_revision=2,option_id='b',idempotency_key='new'))
self.assertEqual(fresh['response']['revision'],2)
history=self.store.decision_history('choice')['history']
self.assertEqual(len([e for e in history if e['kind']=='submission']),2)
self.assertEqual(history[1]['data']['snapshot']['options'][0]['id'],'a')
def test_reconsideration_attention_tracks_status_without_losing_response(self):
self.assertTrue(self.store.state()['attention']['pages']['decisions'])
first=self.store.decision_respond('choice',answer())
self.assertFalse(self.store.state()['attention']['pages']['decisions'])
value=self.store.decision_review('choice',review(outcome='reconsider'))
self.assertEqual(value['response'],first['response'])
self.assertTrue(self.store.state()['attention']['pages']['decisions'])
self.store.decision_respond('choice',answer(idempotency_key='again'))
self.assertFalse(self.store.state()['attention']['pages']['decisions'])
value=self.store.decision_patch('choice',dict(expected_revision=1,reason='Material new facts',question='New question?'))
self.assertIsNotNone(value['response'])
self.assertTrue(self.store.state()['attention']['pages']['decisions'])
self.store.decision_respond('choice',answer(expected_revision=2,idempotency_key='new-revision'))
self.assertFalse(self.store.state()['attention']['pages']['decisions'])
def test_concurrent_patch_has_single_winner(self):
def patch(index):
try: return self.store.decision_patch('choice',dict(expected_revision=1,reason='Evidence',question='Q'+str(index)))
except Conflict: return None
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
results=list(pool.map(patch,range(12)))
self.assertEqual(sum(r is not None for r in results),1)
self.assertEqual(self.store.decision_get('choice')['revision'],2)
for bad in [True,0,'2',None]:
with self.assertRaises(Invalid): self.store.decision_patch('choice',dict(expected_revision=bad,reason='why',title='new'))
with self.assertRaises(Invalid): self.store.decision_patch('choice',dict(expected_revision=2,reason='why',response={}))
def test_related_decisions_are_explicit_and_tasks_validated(self):
with self.assertRaises(Invalid): self.store.decision(definition(id='bad',supersedes_id='bad'))
with self.assertRaises(Invalid): self.store.decision(definition(id='bad',supersedes_id='missing'))
child=self.store.decision(definition(id='next',supersedes_id='choice',summary='Follow up'))
self.assertEqual(child['related_decision_ids'],['choice'])
self.assertEqual(self.store.decision_get('choice')['related_decision_ids'],['next'])
with self.assertRaises(Invalid): self.store.decision_patch('choice',dict(expected_revision=1,reason='why',task_id='missing'))
self.assertEqual(len(self.store.state()['decisions']),2)
def test_bind_isolation_and_identity_is_immutable(self):
with self.assertRaises(Conflict): self.store.decision_consultation('choice')
self.store.session(dict(id='worker',name='Executor',thread_id=THREAD))
self.store.session(dict(id='consult',name='Consult',role='decision',wp='choice',thread_id=THREAD))
data=dict(expected_revision=1,idempotency_key='bind',session_id='consult',thread_id=THREAD)
with self.assertRaises(Conflict): self.store.decision_bind('choice',data)
self.store.session(dict(id='worker',name='Executor',thread_id='00000000-0000-4000-8000-000000000002'))
bound=self.store.decision_bind('choice',data)
self.assertEqual(self.store.decision_bind('choice',data),bound)
with self.assertRaises(Conflict): self.store.decision_bind('choice',dict(data,idempotency_key='other'))
with self.assertRaises(Conflict): self.store.session(dict(id='consult',name='Mutated',role='worker',wp='choice',thread_id=THREAD))
with self.assertRaises(Conflict): self.store.session(dict(id='other',name='Reuse',thread_id=THREAD))
self.assertEqual(self.store.decision_bindings()['bindings'][0]['thread_id'],THREAD)
def test_completion_blocks_pending_inputs_and_consultation_is_readonly_after(self):
bind(self.store)
msg=self.store.decision_consultation('choice',dict(text='Question',client_key='message'))
self.assertEqual(msg,self.store.decision_consultation('choice',dict(text='Question',client_key='message')))
with self.assertRaises(Invalid): self.store.decision_consultation('choice',dict(text='x',client_key='x',session_id='other'))
self.store.decision_respond('choice',answer())
self.store.decision_review('choice',review())
for status in ['queued','claimed','uncertain']:
with self.store.db() as db: db.execute('UPDATE messages SET status=?',(status,))
with self.assertRaises(Conflict): self.store.decision_complete('choice',completion())
with self.store.db() as db: db.execute("UPDATE messages SET status='completed'")
self.store.decision_complete('choice',completion())
with self.assertRaises(Conflict): self.store.decision_consultation('choice',dict(text='Late',client_key='late'))
with self.assertRaises(Conflict): self.store.send(dict(session_id='consult',text='Late',idempotency_key='late'))
self.assertTrue(self.store.decision_consultation('choice')['messages'])
with self.store.db() as db: db.execute("UPDATE messages SET status='uncertain'")
with self.assertRaises(Conflict): self.store.decision_archive('choice',dict(thread_id=THREAD,idempotency_key='archive',receipt='receipt'))
def test_consultation_send_and_completion_are_atomic(self):
bind(self.store)
self.store.decision_respond('choice',answer())
self.store.decision_review('choice',review())
def run(action):
try:
if action=='send':
return action,self.store.decision_consultation('choice',dict(text='Concurrent input',client_key='race'))
return action,self.store.decision_complete('choice',completion())
except Conflict:
return action,None
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
outcomes=dict(pool.map(run,['send','complete']))
self.assertEqual(sum(v is not None for v in outcomes.values()),1)
if outcomes['send'] is not None:
self.assertEqual(self.store.decision_get('choice')['status'],'accepted')
else:
self.assertEqual(self.store.decision_get('choice')['status'],'completed')
self.assertEqual(self.store.messages('consult'),[])
def test_notification_snapshots_and_cancel_only_unsent(self):
notice=self.store.decision_notifications()['notifications'][0]
self.store.decision_submitting(dict(notification_id=notice['notification_id']))
bind(self.store)
self.assertEqual(self.store.decision_notifications()['notifications'][0],notice)
self.store.decision_respond('choice',answer())
submitted=next(n for n in self.store.decision_notifications()['notifications'] if n['event']['kind']=='submitted')
self.store.decision_submitting(dict(notification_id=submitted['notification_id']))
self.store.decision_patch('choice',dict(expected_revision=1,reason='Change',question='New?'))
self.assertIn(submitted,self.store.decision_notifications()['notifications'])
self.assertEqual(submitted['event']['revision'],1)
self.assertEqual(submitted['decision']['question'],'Which approach?')
def test_fact_requests_dedup_answer_isolation_and_notification(self):
bind(self.store)
request=dict(idempotency_key='facts',question='Which version?',context='Need evidence')
fact=self.store.decision_fact('choice',request)
self.assertEqual(fact,Store(self.path).decision_fact('choice',request))
self.store.decision(definition(id='other'))
with self.assertRaises(Invalid): self.store.decision_fact_answer('other',fact['id'],dict(idempotency_key='answer',answer='x'))
body=dict(idempotency_key='answer',answer='Verified version',evidence=['fixture'])
result=self.store.decision_fact_answer('choice',fact['id'],body)
self.assertEqual(result,self.store.decision_fact_answer('choice',fact['id'],body))
notices=self.store.decision_notifications()['notifications']
self.assertFalse(any(n['event']['kind']=='fact_requested' for n in notices))
notice=next(n for n in notices if n['event']['kind']=='fact_answered')
self.assertEqual((notice['target'],notice['decision']['session_thread_id']),('decision',THREAD))
self.assertEqual(self.store.decision_get('choice')['status'],'open')
self.assertFalse(any(e['kind']=='review' for e in self.store.decision_history('choice')['history']))
from agent_control.notices import fact_answer_notice
text=fact_answer_notice(notice)
self.store.mirror(dict(session_id='consult',thread_id=THREAD,items=[
dict(id='fact-input',source='human',text=text,turn_id='fact-turn',phase='input'),
dict(id='fact-final',source='agent',text='Fact explanation',turn_id='fact-turn',phase='final_answer')]))
# ACK does not remove immutable provenance used for history projection.
self.store.decision_ack(dict(notification_id=notice['notification_id']))
self.assertEqual([r['text'] for r in self.store.conversation('consult')],['Fact explanation'])
self.assertEqual(len(self.store.messages('consult')),2)
other_thread='00000000-0000-4000-8000-000000000002'
self.store.session(dict(id='unrelated',name='Other',thread_id=other_thread))
self.store.mirror(dict(session_id='unrelated',thread_id=other_thread,items=[
dict(id='literal',source='human',text=text)]))
self.assertEqual(self.store.conversation('unrelated')[0]['text'],text)
def test_queue_health_reads_workflow_outbox_and_submission_markers(self):
initial=self.store.queue_health()['decisions']
self.assertEqual(initial['pending_count'],1)
self.assertIsNotNone(initial['oldest_pending_at'])
nid=self.store.decision_notifications()['notifications'][0]['notification_id']
self.store.decision_submitting(dict(notification_id=nid))
self.assertEqual(self.store.queue_health()['decisions']['submission_started_unconfirmed_count'],1)
self.store.decision_ack(dict(notification_id=nid))
self.assertEqual(self.store.queue_health()['decisions']['pending_count'],0)
self.store.decision_respond('choice',answer())
self.store.decision_review('choice',review())
health=self.store.queue_health()['decisions']
self.assertEqual(health['pending_count'],1)
self.assertEqual(health['cancelled_count'],1)
def test_legacy_response_migration_never_accepts_or_loses_snapshot(self):
path=Path(self.tmp.name)/'old.sqlite3'
db=sqlite3.connect(path)
db.execute('CREATE TABLE decisions(id TEXT PRIMARY KEY,data TEXT NOT NULL,response TEXT,response_key TEXT)')
original=self.store._normalize_decision(definition())
original.pop('summary');original.pop('supersedes_id')
response=dict(id='legacy-response',option_id='a',comment='Original',created=1,authorization=False)
db.execute('INSERT INTO decisions VALUES(?,?,?,?)',('choice',json.dumps(original),json.dumps(response),'old-key'))
db.commit();db.close()
migrated=Store(path)
current=migrated.decision_get('choice')
self.assertEqual((current['status'],current['revision']),('pending_review',1))
self.assertEqual(current['response']['snapshot']['options'],original['options'])
self.assertIsNone(current['review'])
self.assertEqual(len(Store(path).decision_history('choice')['history']),2)
class WorkflowHTTPTests(unittest.TestCase):
setUp=fixtures.DecisionHTTPTests.setUp
tearDown=fixtures.DecisionHTTPTests.tearDown
request=fixtures.DecisionHTTPTests.request
def test_old_page_submission_without_revision_is_rejected_before_write(self):
self.request('/api/decisions','POST',definition())
old=answer();old.pop('expected_revision')
self.assertEqual(self.request('/api/decisions/choice/respond','POST',old)[0],400)
self.assertIsNone(self.server.store.decision_get('choice')['response'])
self.assertFalse(any(e['kind']=='submission' for e in self.server.store.decision_history('choice')['history']))
first=self.request('/api/decisions/choice/respond','POST',answer())
retry=self.request('/api/decisions/choice/respond','POST',answer())
self.assertEqual(first,retry)
self.assertEqual(first[0],200)
self.assertEqual(sum(e['kind']=='submission' for e in self.server.store.decision_history('choice')['history']),1)
def test_state_reports_current_page_asset_without_changing_decisions(self):
root=Path(self.tmp.name)/'frontend';root.mkdir()
self.server.web_root=root
(root/'index.html').write_text('')
self.assertEqual(self.request('/api/state')[1]['frontend_entrypoint'],'/assets/index-first.js')
(root/'index.html').write_text('')
self.assertEqual(self.request('/api/state')[1]['frontend_entrypoint'],'/assets/index-next.js')
(root/'index.html').unlink()
self.assertIsNone(self.request('/api/state')[1]['frontend_entrypoint'])
def test_limited_credential_route_matrix_and_closed_writes(self):
store=self.server.store
store.decision(definition());bind(store)
code,issued=self.request('/api/decisions/choice/consultation-credential','POST',{})
self.assertEqual(code,200)
token=issued['token'];headers={'Authorization':'Bearer '+token}
with store.db() as db:
rows=[dict(r) for r in db.execute('SELECT * FROM decision_credentials')]
self.assertEqual(rows[0]['token_hash'],hashlib.sha256(token.encode()).hexdigest())
self.assertNotIn(token,json.dumps(rows))
for path in ['/api/decisions/choice','/api/decisions/choice/history','/api/decisions/choice/consultation']:
self.assertEqual(self.request(path,headers=headers)[0],200)
for method,path,payload in [('GET','/api/state',None),('GET','/api/decisions/other',None),('GET','/api/decision-bridge-bindings',None),
('POST','/api/decisions/choice/respond',answer()),('POST','/api/decisions/choice/review',review()),
('POST','/api/decisions/choice/complete',completion()),('POST','/api/sessions',{}),
('POST','/api/decisions/choice/consultation',dict(text='x',client_key='x')),
('POST','/api/decisions/choice/consultation-credential',{}),
('POST','/api/decisions/choice/bind-session',{}),('POST','/api/decisions/choice/archive-receipt',{})]:
with self.subTest(path=path): self.assertEqual(self.request(path,method,payload,headers)[0],403)
for field,value in [('task_id','other-task'),('supersedes_id','other'),('requires_authorization',False)]:
self.assertEqual(self.request('/api/decisions/choice','PATCH',dict(expected_revision=1,reason='Out of scope',**{field:value}),headers)[0],403)
self.assertEqual(self.request('/api/decisions/choice','PATCH',dict(expected_revision=1,reason='Evidence',summary='Updated'),headers)[0],200)
self.assertEqual(self.request('/api/decisions/choice/facts','POST',dict(idempotency_key='fact',question='Fact?'),headers)[0],200)
store.decision_respond('choice',answer(expected_revision=2));store.decision_review('choice',review(expected_revision=2));store.decision_complete('choice',completion(expected_revision=2))
self.assertEqual(self.request('/api/decisions/choice','PATCH',dict(expected_revision=2,reason='Late',summary='x'),headers)[0],403)
self.assertEqual(self.request('/api/decisions/choice/facts','POST',dict(idempotency_key='late',question='Late?'),headers)[0],403)
self.assertEqual(self.request('/api/decisions/choice/history',headers=headers)[0],200)
def test_workflow_http_endpoints_and_conflicts(self):
self.request('/api/decisions','POST',definition())
self.assertEqual(self.request('/api/decisions/choice/consultation')[0],409)
bind(self.server.store)
self.assertEqual(self.request('/api/decision-bridge-bindings')[0],200)
self.assertEqual(self.request('/api/decisions/choice/respond','POST',answer())[0],200)
self.assertEqual(self.request('/api/decisions/choice/review','POST',review())[0],200)
self.assertEqual(self.request('/api/decisions/choice/complete','POST',completion())[0],200)
self.assertEqual(self.request('/api/decisions/choice/archive-receipt','POST',dict(thread_id=THREAD,idempotency_key='archive',receipt='tool receipt'))[0],200)
self.assertEqual(self.request('/api/decisions/choice/history')[1]['history'][-1]['kind'],'archive')
if __name__=='__main__': unittest.main()