TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details. Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
178 lines
8.8 KiB
Python
178 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Local-only deterministic Agent contract fixture. Never contacts external services.
|
|
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
|
"""
|
|
import asyncio
|
|
import datetime
|
|
import json
|
|
from aiohttp import web
|
|
|
|
sockets = set()
|
|
messages = []
|
|
requests = {}
|
|
read_cursor = 0
|
|
cursor = 0
|
|
busy = False
|
|
attempts = {}
|
|
log = []
|
|
reply_tasks = set()
|
|
|
|
def stamp():
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
|
|
|
def activity():
|
|
return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0,
|
|
backgroundWorkers=0, tools=[], queuedMessages=[])
|
|
|
|
def message(content, role='assistant', id=None, info=None):
|
|
global cursor
|
|
cursor += 1
|
|
row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(),
|
|
metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {})))
|
|
messages.append(row)
|
|
return row
|
|
|
|
def page(rows, position=None, more=False):
|
|
return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more)
|
|
|
|
async def broadcast(frame):
|
|
for socket in list(sockets):
|
|
try:
|
|
await socket.send_json(frame)
|
|
except ConnectionError:
|
|
sockets.discard(socket)
|
|
|
|
async def emit(content):
|
|
row = message(content)
|
|
await broadcast(dict(type='messages', **page([row])))
|
|
return row
|
|
|
|
async def reply(content):
|
|
global busy, cursor
|
|
await asyncio.sleep(1.2)
|
|
tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}')
|
|
tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp())
|
|
await broadcast(dict(type='messages', **page([tool])))
|
|
await asyncio.sleep(0.8)
|
|
cursor += 1
|
|
tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches'))
|
|
messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool
|
|
await broadcast(dict(type='messages', **page([tool])))
|
|
failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}')
|
|
failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure')
|
|
await broadcast(dict(type='messages', **page([failed])))
|
|
await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.')
|
|
# Deliberately stale busy frame after the public reply.
|
|
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
|
await asyncio.sleep(0.5)
|
|
busy = False
|
|
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
|
|
|
async def route(request):
|
|
global read_cursor, cursor, busy
|
|
path = request.path
|
|
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
|
if path == '/control':
|
|
body = await request.json()
|
|
action = body.get('action')
|
|
if action == 'reset':
|
|
for task in reply_tasks:
|
|
task.cancel()
|
|
reply_tasks.clear()
|
|
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
|
for i in range(body.get('count', 3)):
|
|
message(f'History message {i + 1}: local simulator fixture.')
|
|
elif action == 'reply':
|
|
await emit(body.get('content', 'Late asynchronous fixture reply'))
|
|
elif action == 'long-tool':
|
|
row = message('Local tool output', 'tool')
|
|
row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed',
|
|
args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80)))
|
|
await broadcast(dict(type='messages', **page([row])))
|
|
elif action == 'busy':
|
|
busy = body.get('value', True)
|
|
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
|
elif action == 'read':
|
|
read_cursor = cursor
|
|
elif action == 'disconnect':
|
|
for socket in list(sockets):
|
|
await socket.close(code=1012)
|
|
elif action == 'malformed':
|
|
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
|
|
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
|
if path == '/api/v1/auth/session':
|
|
return web.json_response(dict(authenticated=True, mode='token'))
|
|
if path == '/api/v1/workspace-items':
|
|
return web.json_response(dict(items=[], hasMore=False))
|
|
if path == '/api/v1/chats':
|
|
return web.json_response(dict(chats=[]))
|
|
if path == '/api/v1/searches':
|
|
return web.json_response(dict(searches=[]))
|
|
if path == '/api/v1/models':
|
|
return web.json_response(dict(providers={}))
|
|
if path == '/api/v1/active-runs':
|
|
return web.json_response(dict(chats=[], searches=[]))
|
|
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
|
|
if request.headers.get('Authorization') != 'Bearer fixture-token':
|
|
return web.json_response(dict(error='Fixture token missing'), status=401)
|
|
if path == '/api/v1/agent':
|
|
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
|
|
if path == '/api/v1/agent/read-state':
|
|
if request.method == 'PUT':
|
|
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
|
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
|
|
if path == '/api/v1/agent/messages':
|
|
if request.method == 'POST':
|
|
body = await request.json()
|
|
key, content = body['requestId'], body['content']
|
|
attempts[key] = attempts.get(key, 0) + 1
|
|
if content == 'retry fixture' and attempts[key] == 1:
|
|
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
|
|
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
|
|
if key not in requests:
|
|
requests[key] = receipt
|
|
row = message(content, 'user', id=receipt['messageId'])
|
|
busy = True
|
|
await broadcast(dict(type='messages', **page([row])))
|
|
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
|
task = asyncio.create_task(reply(content))
|
|
reply_tasks.add(task)
|
|
task.add_done_callback(reply_tasks.discard)
|
|
if content == 'echo wins fixture':
|
|
await asyncio.sleep(0.3)
|
|
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
|
|
return web.json_response(requests[key], status=202)
|
|
after = int(request.query.get('after', 0))
|
|
rows = [m for m in messages if m['metadata']['entryId'] > after]
|
|
limit = min(int(request.query.get('limit', 200)), 200)
|
|
selected = rows[:limit]
|
|
return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit))
|
|
if path == '/api/v1/agent/ws':
|
|
if request.query:
|
|
return web.Response(status=400)
|
|
socket = web.WebSocketResponse(); await socket.prepare(request)
|
|
auth = await socket.receive_json(timeout=5)
|
|
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
|
|
await socket.close(code=4401); return socket
|
|
log.append(dict(authAfter=auth.get('after')))
|
|
sockets.add(socket)
|
|
await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root'))
|
|
await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))])))
|
|
await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
|
try:
|
|
async for frame in socket:
|
|
if frame.type == web.WSMsgType.TEXT:
|
|
data = json.loads(frame.data)
|
|
if data.get('type') == 'ping':
|
|
await socket.send_json(dict(type='pong'))
|
|
finally:
|
|
sockets.discard(socket)
|
|
return socket
|
|
return web.json_response(dict(error='No fixture for this endpoint'), status=404)
|
|
|
|
for i in range(3):
|
|
message(f'History message {i + 1}: local simulator fixture.')
|
|
app = web.Application()
|
|
app.router.add_route('*', '/{path:.*}', route)
|
|
web.run_app(app, host='127.0.0.1', port=8799)
|