ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s

This commit is contained in:
2026-10-14 19:51:27 -07:00
parent 2e62bfa736
commit 80748f764f
17 changed files with 2114 additions and 60 deletions
+124 -8
View File
@@ -5,6 +5,10 @@ Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
import asyncio
import datetime
import json
import hashlib
import unicodedata
import uuid
from urllib.parse import quote
from aiohttp import web
sockets = set()
@@ -16,6 +20,21 @@ busy = False
attempts = {}
log = []
reply_tasks = set()
attachments = {}
uploads = {}
attachment_capability = True
next_upload_error = None
blocked_proxy = False
transcript_comparison = False
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
def attachment_error(status, code, message, **fields):
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
headers={"Retry-After": "2"} if status == 429 else None)
def stamp():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
@@ -69,7 +88,7 @@ async def reply(content):
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
async def route(request):
global read_cursor, cursor, busy
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
path = request.path
log.append(dict(method=request.method, path=path, query=dict(request.query)))
if path == '/control':
@@ -80,8 +99,21 @@ async def route(request):
task.cancel()
reply_tasks.clear()
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
elif action == 'transcript-comparison':
transcript_comparison = True
elif action == 'capability':
attachment_capability = body.get('enabled', True)
elif action == 'upload-error':
next_upload_error = body
elif action == 'blocked-proxy':
blocked_proxy = body.get('enabled', True)
elif action == 'attachment-history':
selected = [attachments[key]['descriptor'] for key in body['ids']]
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
await broadcast(dict(type='messages', **page([row])))
elif action == 'reply':
await emit(body.get('content', 'Late asynchronous fixture reply'))
elif action == 'long-tool':
@@ -102,10 +134,13 @@ async def route(request):
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'))
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
if path == '/api/v1/workspace-items':
return web.json_response(dict(items=[], hasMore=False))
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
if path == '/api/v1/chats':
return web.json_response(dict(chats=[]))
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
return web.json_response(dict(chat=dict(comparison, messages=messages)))
if path == '/api/v1/searches':
return web.json_response(dict(searches=[]))
if path == '/api/v1/models':
@@ -115,8 +150,70 @@ async def route(request):
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 blocked_proxy:
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
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()))
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
if attachment_capability:
descriptor['capabilities'] = {'attachments': CAPABILITY}
return web.json_response(descriptor)
if path == '/api/v1/agent/attachments' and request.method == 'POST':
if not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if next_upload_error:
failure = next_upload_error; next_upload_error = None
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
reader = await request.multipart()
parts = {}
while part := await reader.next():
if part.name not in ('file', 'uploadId') or part.name in parts:
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
if set(parts) != {'file', 'uploadId'}:
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
try:
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
filename = unicodedata.normalize('NFC', parts['file'][0])
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
except (ValueError, AssertionError, TypeError):
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
_, mime, data = parts['file']
mime = (mime or 'application/octet-stream').lower()
if len(data) > CAPABILITY['maxFileBytes']:
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
digest = hashlib.sha256(data).hexdigest()
identity = (filename, mime, digest)
if upload_id in uploads:
old_identity, attachment_id = uploads[upload_id]
if old_identity != identity:
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
kind = 'file'
if mime in CAPABILITY['imageMimeTypes']:
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
elif mime.startswith('text/'):
try:
if '\0' not in data.decode('utf-8-sig'):
kind = 'text'
except UnicodeDecodeError:
pass
attachment_id = 'fixture-' + str(uuid.uuid4())
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
uploads[upload_id] = (identity, attachment_id)
return web.json_response({'attachment': descriptor}, status=201)
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
stored = attachments.get(attachment_id)
if stored is None:
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
descriptor = stored['descriptor']
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
if path == '/api/v1/agent/read-state':
if request.method == 'PUT':
read_cursor = max(read_cursor, int((await request.json())['cursor']))
@@ -125,13 +222,29 @@ async def route(request):
if request.method == 'POST':
body = await request.json()
key, content = body['requestId'], body['content']
refs = body.get('attachments', [])
ids = [ref.get('id') for ref in refs]
if refs and not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
if any(key not in attachments for key in ids):
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
if not content.strip() and not ids:
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
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 ids:
receipt['attachmentIds'] = ids
if key not in requests:
requests[key] = receipt
row = message(content, 'user', id=receipt['messageId'])
requests[key] = dict(receipt, _payload=[content, ids])
descriptors = [attachments[key]['descriptor'] for key in ids]
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
busy = True
await broadcast(dict(type='messages', **page([row])))
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
@@ -141,7 +254,7 @@ async def route(request):
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)
return web.json_response({k: v for k, v in requests[key].items() if k != '_payload'}, 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)
@@ -151,6 +264,9 @@ async def route(request):
if request.query:
return web.Response(status=400)
socket = web.WebSocketResponse(); await socket.prepare(request)
if blocked_proxy:
await socket.close(code=4503, message=b'attachments_unavailable')
return socket
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
@@ -172,6 +288,6 @@ async def route(request):
for i in range(3):
message(f'History message {i + 1}: local simulator fixture.')
app = web.Application()
app = web.Application(client_max_size=8 * 1024 * 1024 + 64 * 1024)
app.router.add_route('*', '/{path:.*}', route)
web.run_app(app, host='127.0.0.1', port=8799)