A continuous stream of features shipped in the JuntoAI A2A Protocol Sandbox.
EspoCRM Contact Sync
espocrm_service.py module with sync_contact async function: upsert contact via GET → POST/PUT /api/v1/Contact, httpx.AsyncClient with 10s timeout and X-Api-Key auth header
build_contact_payload pure function: normalised email, first/last name from display_name or email local part, hardcoded juntoaiServices=["a2a"] and juntoaiMarketingEmail=True, juntoaiRegisteredAt/juntoaiConsentTimestamp from signed_up_at, accountId and teamsIds from settings
Guard conditions: RUN_MODE=local → skip silently; empty ESPOCRM_URL or ESPOCRM_API_KEY → log WARNING + skip; all error paths return CrmSyncResult, never raise
CrmSyncResult Pydantic V2 model (email, action: created/updated/skipped/error, detail: str | None) added to app/models/admin.py
Four EspoCRM settings added to Settings: ESPOCRM_URL, ESPOCRM_API_KEY, ESPOCRM_JUNTOAI_MINI_ACCOUNT_ID, ESPOCRM_JUNTOAI_TEAM_ID — loaded via existing pydantic-settings .env mechanism
Auto-sync on signup: fire-and-forget asyncio.create_task in POST /auth/join cloud path after Firestore write; new users only, no await, LoginResponse never modified
POST /api/v1/admin/users/{email}/sync-crm endpoint: awaited sync, returns CrmSyncResult HTTP 200 for all action values, 404 if user not in waitlist, 503 in local mode via verify_admin_session
HTTP error handling: 4xx/5xx → log status + body (truncated 500 chars) + action=error; network/timeout exceptions → log type + message + action=error
Property tests (7 Hypothesis properties): email normalisation round-trip, all required fields present, hardcoded field invariants, name splitting from display_name, name fallback from email local part, consent timestamp equals registered-at, sync_contact never raises
Unit tests: new contact creation, existing contact update, local/unconfigured skip, X-Api-Key header, timeout, HTTP 4xx/5xx, TimeoutException, admin endpoint (200/404/503/error passthrough)
Delete custom scenario with cascade: DELETE endpoint identifies and removes all connected negotiation sessions before deleting the scenario, returns deleted_sessions_count
Cascade delete user warning: confirmation dialog fetches session count and displays warning before proceeding with explicit confirm/cancel
list_sessions_by_scenario and delete_session methods added to SessionStore protocol with Firestore and SQLite implementations
update() method on both CustomScenarioStore (Firestore) and SQLiteCustomScenarioStore — overwrites scenario_json and updated_at
PUT /builder/scenarios/{scenario_id} endpoint: validates against ArenaScenario Pydantic model, returns 422 with specific validation errors on failure
ScenarioEditorModal component: monospace textarea with 2-space indented JSON, inline editable name field (max 100 chars), client-side JSON.parse validation
Backend validation errors displayed inline below textarea on 422 without closing the modal
Pencil icon edit button on custom scenarios in ScenarioSelector — hidden for built-in scenarios alongside existing delete button
DeleteConfirmDialog component: fetches session count, displays warning, confirm/cancel with loading state during delete
Ownership enforcement: 404 for non-existent or non-owned scenarios, 401 for missing email, 403 for built-in scenario operations
Abort-on-failure semantics: if any session deletion fails during cascade, entire operation aborts with HTTP 500
Model registry extended with gemini-3.1-pro-preview and gemini-3.1-flash-lite-preview — VALID_MODEL_IDS, MODELS_PROMPT_BLOCK, and DEFAULT_MODEL_MAP derive automatically
Startup availability probe: concurrent asyncio.gather probes of all registered models via Vertex AI (cloud) or LiteLLM (local) with configurable 15s timeout
ProbeResult and AllowedModels frozen dataclasses — immutable after construction, stored in app.state.allowed_models for dependency injection
Zero-model degraded mode: application starts with empty allowed list and degraded health status instead of crashing
/api/v1/models endpoint returns only verified-working models from the Allowed Models List
Scenario registry available boolean flag: validates each agent's model_id and fallback_model_id against allowed set at load time
Builder prompt block filtered to allowed models only — generated scenarios reference only reachable LLMs
Health endpoint enhanced with models object (total_registered, total_available), unavailable_models list, and degraded status when zero models available
GET /admin/models endpoint: per-model probe status, error reason, latency_ms, summary counts, 503 if probes not yet complete
Cloud Run metric alerting policies: Backend High CPU (>80%), Backend High Memory (>85%), Backend/Frontend High Error Rate (5xx >10), Backend Instance Count Spike
All policies: configurable thresholds via Terraform variables, auto_close 1800s, notification rate limit 300s, absent data = not firing, open + close notifications
Cloud Function (2nd gen, Python 3.11+, 256MB, 60s timeout) with Pub/Sub event trigger and message type detection
Telegram notifier: alarm/resolved emoji prefixes for alerting incidents, build failed prefix for CI/CD failures, HTML formatting via sendMessage API
Cloud Build failure notifications from cloud-builds topic — SUCCESS events discarded, failures include trigger, branch, commit SHA, duration, log URL
Secret caching, non-2xx retry via exception, unknown schema discarding with warning log
Structural tests for module files, variables, outputs, and Terragrunt config
Unit tests for message type detection, parse/format functions, send_telegram_message (mocked HTTP), SUCCESS discarding, unknown schema handling
Browser notification via Web Notification API when a negotiation reaches a terminal state (Agreed, Blocked, Failed) while the tab is hidden
buildNotificationContent pure function: status-to-title mapping (Deal Agreed, Deal Blocked, Negotiation Failed) with body from finalSummary fields and fallback strings
useNotification React hook: permission request on mount when default, visibility gate (document.hidden), deduplication via useRef keyed by session ID
Click handler: window.focus() then notification.close() to bring user back to the Glass Box page
Notification tag set to session ID to prevent duplicate OS-level notifications for the same session
JuntoAI application icon (icon-192.png) included in notification payload
Graceful degradation: Notification API unavailable or permission denied — all notification logic skipped, zero errors or visual changes
Error handling: try/catch around requestPermission() and new Notification() — failures logged to console, never break app
Dedup tracking reset on component unmount to allow re-notification on page revisit
Frontend-only feature — hooks into existing NegotiationCompleteEvent SSE dispatch, no backend changes
Unit tests: permission request, skip when granted/denied, rejection handling, API unavailable, click handler, constructor throws, unmount reset
Social Sharing
Completed negotiation replay: terminal sessions stream reconstructed SSE events from persisted history without re-running the orchestrator
Glass Box replay mode (?mode=replay): hides Stop Negotiation button and warm-up spinner, shows Loading negotiation instead of Connecting
SharePayload Pydantic V2 model with 8-char alphanumeric slug, session metadata, participant summaries, and deal outcome — excludes raw history and sensitive data
ShareStore protocol with FirestoreShareClient and SQLiteShareClient — idempotent creation returns existing slug for same session_id
Share image generation via Vertex AI Imagen with 15s timeout; fallback to static branded placeholder on failure
Social post composition: one-sentence summary, participant roles, share URL, Created with @JuntoAI A2A branding, and hashtags
Share API: POST /api/v1/share (session ownership validation, lazy creation) and GET /api/v1/share/{slug} (public, no auth)
SharePanel on Outcome Receipt: LinkedIn, X/Twitter, Facebook, Copy Link (clipboard with selectable text fallback), and Email (mailto with pre-filled subject/body)
Lazy share creation: first button click triggers API call, caches response; loading state disables all buttons during creation
Public share page at /share/{slug}: unauthenticated, server-rendered with Open Graph and Twitter Card meta tags
JuntoAI branded header on share page with Try JuntoAI A2A CTA linking to landing page
Responsive layout: horizontal share buttons at 1024px+, 2-column grid on mobile; share page renders 320px to 1920px
Property tests (8 Hypothesis/fast-check properties): SharePayload round-trip, slug uniqueness, idempotent creation, sensitive data exclusion, social post constraints, mailto composition, meta tags
LLM Usage Summary
compute_usage_summary(agent_calls) pure aggregator: groups by agent_role and model_id, computes per-persona stats, per-model stats, session-wide totals, and negotiation_duration_ms
PersonaUsageStats, ModelUsageStats, UsageSummary Pydantic V2 models with ge=0 constraints and JSON round-trip property
Edge cases: empty list → zero-valued summary, all-error persona → tokens_per_message = 0, single record → duration 0
usage_summary key added to final_summary in NegotiationCompleteEvent at all terminal-state code paths
Existing ai_tokens_used field preserved — usage summary is additive, not a replacement
Collapsible LLM Usage section on Outcome Receipt, collapsed by default with toggle button
Per-persona breakdown table sorted by total_tokens descending: agent_role, model_id, total_tokens, call_count, avg_latency_ms, tokens_per_message, input:output ratio
Property tests (Hypothesis + fast-check): JSON round-trip, aggregation correctness, persona sorting, ratio string, most-verbose-badge placement
Negotiation History Panel
GET /api/v1/negotiation/history endpoint returning completed sessions grouped by UTC day with configurable days parameter (1–90, default 7)
SessionHistoryItem, DayGroup, SessionHistoryResponse Pydantic V2 models with field constraints and round-trip serialization
Shared compute_token_cost utility: max(1, ceil(total_tokens_used / 1000)) — replaces inline formula in stream_negotiation
list_sessions_by_owner on both FirestoreSessionClient and SQLiteSessionClient with date filtering and descending sort
Day groups sorted descending by date, sessions within groups sorted descending by created_at, terminal sessions only (Agreed, Blocked, Failed)
NegotiationHistory panel below InitializeButton on /arena page: collapsible day groups, colored status badges, daily token cost as fraction of daily limit
Today group expanded by default, others collapsed; Today / Yesterday labels for recent dates
Session replay navigation: View link to /arena/session/{session_id} loads read-only Glass Box replay for terminal sessions
Loading skeleton, error state with retry, empty state messaging
Local mode: SQLite query with JSON column owner_email extraction, ∞ for unlimited daily token display
Responsive layout: single-column below 1024px, max-w-4xl container at 1024px+
Property tests (Hypothesis): token cost formula, response round-trip, grouping/sorting correctness, date range filtering, DayGroup token cost sum invariant
AI Scenario Builder
AI-powered interactive scenario builder: guided chatbot (Claude Opus 4.6 via Vertex AI) produces validated ArenaScenario JSON configs
Build Your Own Scenario entry point in ScenarioSelector with My Scenarios group for saved custom scenarios
Split-screen Builder Modal: chatbot on left, live JSON preview with syntax highlighting on right, progress indicator at top