Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions __tests__/api/agents/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from "vitest"
import { GET } from "@/app/api/agents/leaderboard/route"
import { resetAgentHealthStore, seedAgentHealthRecord } from "@/lib/agents/agent-health-store"
import { awardXP } from "@/lib/gamification/xp"
import { upsertReputationMetrics } from "@/lib/reputation/reputation-store"

describe("GET /api/agents/leaderboard", () => {
beforeEach(() => {
resetAgentHealthStore()
})

it("returns 400 Bad Request if limit > 100", async () => {
const req = new Request("http://localhost/api/agents/leaderboard?limit=101")
const res = await GET(req)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error).toBe("Bad Request: Limit cannot exceed 100")
})

it("sorts by XP by default and applies limits", async () => {
const prefix = "xp-agent-"
for (let i = 1; i <= 5; i++) {
const agentId = `${prefix}${i}`
seedAgentHealthRecord(agentId)
awardXP(agentId, i * 100, "task.completed")
}

const req = new Request(`http://localhost/api/agents/leaderboard?limit=2`)
const res = await GET(req)
expect(res.status).toBe(200)
const data = await res.json()

expect(data.total).toBe(5)
expect(data.limit).toBe(2)
expect(data.offset).toBe(0)
expect(data.agents.length).toBe(2)

Check warning on line 36 in __tests__/api/agents/leaderboard.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(data.agents).toHaveLength(2)".

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXadTpqeD2E2G6i4o&open=AZ8JXadTpqeD2E2G6i4o&pullRequest=369
expect(data.agents[0].agentId).toBe(`${prefix}5`)
expect(data.agents[0].rank).toBe(1)
expect(data.agents[1].agentId).toBe(`${prefix}4`)
expect(data.agents[1].rank).toBe(2)
})

it("sorts by reputation", async () => {
const prefix = "rep-agent-"
for (let i = 1; i <= 5; i++) {
const agentId = `${prefix}${i}`
seedAgentHealthRecord(agentId)
upsertReputationMetrics(agentId, { tasksCompleted: i * 50 })
}

const req = new Request(`http://localhost/api/agents/leaderboard?sort=reputation&limit=3`)
const res = await GET(req)
const data = await res.json()

expect(data.agents.length).toBe(3)

Check warning on line 55 in __tests__/api/agents/leaderboard.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(data.agents).toHaveLength(3)".

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXadTpqeD2E2G6i4p&open=AZ8JXadTpqeD2E2G6i4p&pullRequest=369
expect(data.agents[0].agentId).toBe(`${prefix}5`)
expect(data.agents[0].rank).toBe(1)
expect(data.agents[2].agentId).toBe(`${prefix}3`)
expect(data.agents[2].rank).toBe(3)
})

it("sorts by tasks and handles offset correctly", async () => {
const prefix = "task-agent-"
for (let i = 1; i <= 5; i++) {
const agentId = `${prefix}${i}`
seedAgentHealthRecord(agentId)
upsertReputationMetrics(agentId, { tasksCompleted: i * 10 })
}

const req = new Request(`http://localhost/api/agents/leaderboard?sort=tasks&offset=2&limit=2`)
const res = await GET(req)
const data = await res.json()

expect(data.total).toBe(5)
expect(data.offset).toBe(2)
expect(data.agents.length).toBe(2)

Check warning on line 76 in __tests__/api/agents/leaderboard.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(data.agents).toHaveLength(2)".

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXadTpqeD2E2G6i4q&open=AZ8JXadTpqeD2E2G6i4q&pullRequest=369

expect(data.agents[0].agentId).toBe(`${prefix}3`)
expect(data.agents[0].rank).toBe(3)
expect(data.agents[1].agentId).toBe(`${prefix}2`)
expect(data.agents[1].rank).toBe(4)
})
})
16 changes: 16 additions & 0 deletions __tests__/lib/agents/position-cluster.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { clusterPositions } from '@/lib/agents/position-cluster'

describe('clusterPositions', () => {
it('clusters 3 agents within 2km with gridSize=5 -> 1 cluster of 3', () => {
const positions = [
{ agentId: 'a1', lat: 40.7300, lng: -73.9950 },
{ agentId: 'a2', lat: 40.7310, lng: -73.9960 },
{ agentId: 'a3', lat: 40.7320, lng: -73.9970 },
]
const clusters = clusterPositions(positions, 5)
expect(clusters.length).toBe(1)

Check warning on line 12 in __tests__/lib/agents/position-cluster.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(clusters).toHaveLength(1)".

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXaddpqeD2E2G6i4r&open=AZ8JXaddpqeD2E2G6i4r&pullRequest=369
expect(clusters[0].count).toBe(3)
expect(clusters[0].agentIds).toEqual(['a1', 'a2', 'a3'])
})
})
222 changes: 192 additions & 30 deletions app/agents/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Metadata } from "next"
import Image from "next/image"

Check warning on line 2 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'Image'.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXacxpqeD2E2G6i4g&open=AZ8JXacxpqeD2E2G6i4g&pullRequest=369
import Link from "next/link"
import { notFound } from "next/navigation"

import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"

import {
AGENT_OG_SIZE,
findAgentByLookup,
Expand All @@ -19,11 +24,9 @@
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}

if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`
}

return "http://localhost:3000"
}

Expand Down Expand Up @@ -77,16 +80,75 @@
}
}

export default async function AgentPage({ params }: AgentPageProps) {

Check failure on line 83 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXacxpqeD2E2G6i4h&open=AZ8JXacxpqeD2E2G6i4h&pullRequest=369
const { id } = await params
const agent = findAgentByLookup(id)

// Data loading as required by acceptance criteria
const [metaRes, healthRes, repRes, questRes] = await Promise.all([
fetch(absoluteUrl(`/api/agents/${id}`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/agents/${id}/health`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/protocol/reputation?actorId=${id}`), { cache: 'no-store' }),
fetch(absoluteUrl(`/api/agents/${id}/quest-recommendations`), { cache: 'no-store' })
])

if (!agent) {
const localAgent = findAgentByLookup(id)
if (!metaRes.ok && !localAgent) {
notFound()
}

const stats = getAgentCardStats(agent)
const district = getAgentDistrict(agent)
// Parse Metadata
let agentMetadata: any = null
let capabilities: string[] = []
if (metaRes.ok) {
const data = await metaRes.json()
agentMetadata = data.agent
capabilities = agentMetadata.capabilities || []
} else {
agentMetadata = localAgent
capabilities = localAgent?.skills?.map((s: any) => s.name) || []
}

// Parse Health
let isHealthy = false
let uptime = "0s"
if (healthRes.ok) {
const data = await healthRes.json()
isHealthy = data.health?.status === 'healthy'
uptime = data.health?.uptime || "0s"
} else if (localAgent) {
isHealthy = true
uptime = `${getAgentCardStats(localAgent).uptime}%`
}

// Parse Reputation
let repScore = 0
let badges: any[] = []
let infractions = 0
if (repRes.ok) {
const data = await repRes.json()
repScore = data.reputation?.score || 0
badges = data.reputation?.badges || []
infractions = data.reputation?.history?.filter((h: any) => h.delta < 0).length || 0
}

// Parse Quests
let quests: any[] = []
if (questRes.ok) {
const data = await questRes.json()
quests = data.quests || []
}

const agentName = agentMetadata.name || agentMetadata.agentId || 'Unknown Agent'
const initials = agentName.substring(0, 2).toUpperCase()
const agentIdStr = agentMetadata.agentId || agentMetadata.id || id
const tasksCompleted = agentMetadata.tasksCompleted ?? localAgent?.tasksCompleted ?? 0

let districtName = "Unknown"
if (agentMetadata.district) {
districtName = typeof agentMetadata.district === 'string' ? agentMetadata.district : agentMetadata.district.name
} else if (localAgent) {
districtName = getAgentDistrict(localAgent).name
}

return (
<main className="min-h-screen bg-[#030712] px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
Expand All @@ -97,35 +159,135 @@
>
Back to city
</Link>

<section className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4 shadow-[0_24px_80px_rgba(2,8,23,0.45)] sm:p-5">
<div className="mb-5">
<p className="font-mono text-xs uppercase tracking-[0.28em] text-cyan-300">Open Stellar agent</p>
<h1 className="mt-3 font-pixel text-2xl uppercase text-slate-100 sm:text-3xl" style={{ color: agent.color }}>
{agent.name}
</h1>
<p className="mt-3 font-mono text-sm text-slate-400">
Level {stats.level} / {stats.tier} / {district.name}
</p>
<Link
href={`/credential/${encodeURIComponent(agent.id)}`}
className="mt-4 inline-flex w-fit rounded-md border border-emerald-400/30 bg-emerald-400/10 px-3 py-2 font-mono text-xs uppercase tracking-[0.2em] text-emerald-200 transition hover:border-emerald-300/60"

{/* Header Section */}
<Card className="bg-slate-950/80 border-slate-800 shadow-[0_24px_80px_rgba(2,8,23,0.45)]">
<CardHeader className="flex flex-col sm:flex-row items-center gap-6 pb-6">
<Avatar className="h-24 w-24 border-2 border-slate-800 bg-slate-900 flex-shrink-0">
<AvatarImage src={`/sprites/robot-blue.gif`} alt={agentName} className="object-cover" />
<AvatarFallback className="bg-slate-800 text-2xl font-mono text-cyan-300">{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2 items-center sm:items-start flex-1 text-center sm:text-left">
<div className="flex flex-col sm:flex-row items-center gap-3">
<h1 className="font-pixel text-2xl sm:text-3xl uppercase text-slate-100" style={{ color: localAgent?.color }}>{agentName}</h1>
<Badge variant={isHealthy ? "default" : "destructive"} className={isHealthy ? "bg-emerald-500/20 text-emerald-400 border-emerald-500/50" : ""}>
{isHealthy ? "Healthy" : "Offline"}
</Badge>
</div>
<p className="font-mono text-sm text-slate-400 mt-1">ID: {agentIdStr}</p>
<div className="flex items-center gap-2 font-mono text-xs text-cyan-400/80 mt-2 bg-cyan-950/30 px-3 py-1.5 rounded-full border border-cyan-900/50">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
{districtName}
</div>
</div>
<Link
href={`/credential/${encodeURIComponent(agentIdStr)}`}
className="mt-2 sm:mt-0 w-fit rounded-md border border-emerald-400/30 bg-emerald-400/10 px-4 py-2 font-mono text-xs uppercase tracking-[0.2em] text-emerald-200 transition hover:border-emerald-300/60"
>
Reputation credential
View Credential
</Link>
</CardHeader>
</Card>

<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 flex flex-col gap-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card className="bg-slate-950/80 border-slate-800">
<CardContent className="p-4 flex flex-col items-center justify-center text-center">
<span className="font-mono text-xs text-slate-400 mb-1">Reputation</span>
<span className="font-pixel text-2xl text-amber-400">{repScore}</span>
</CardContent>
</Card>
<Card className="bg-slate-950/80 border-slate-800">
<CardContent className="p-4 flex flex-col items-center justify-center text-center">
<span className="font-mono text-xs text-slate-400 mb-1">Tasks Done</span>
<span className="font-pixel text-2xl text-cyan-400">{tasksCompleted}</span>
</CardContent>
</Card>
<Card className="bg-slate-950/80 border-slate-800">
<CardContent className="p-4 flex flex-col items-center justify-center text-center">
<span className="font-mono text-xs text-slate-400 mb-1">Uptime</span>
<span className="font-pixel text-2xl text-emerald-400">{uptime}</span>
</CardContent>
</Card>
<Card className="bg-slate-950/80 border-slate-800">
<CardContent className="p-4 flex flex-col items-center justify-center text-center">
<span className="font-mono text-xs text-slate-400 mb-1">Infractions</span>
<span className="font-pixel text-2xl text-rose-400">{infractions}</span>
</CardContent>
</Card>
</div>

{/* Capabilities */}
<Card className="bg-slate-950/80 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="font-mono uppercase tracking-wider text-sm text-slate-300">Capabilities</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{capabilities.length > 0 ? capabilities.map((cap, i) => (
<Badge key={i} variant="outline" className="bg-blue-900/20 text-blue-300 border-blue-800/50 hover:bg-blue-900/40 px-3 py-1 text-xs">

Check warning on line 230 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXacxpqeD2E2G6i4i&open=AZ8JXacxpqeD2E2G6i4i&pullRequest=369
{cap}
</Badge>
)) : (
<span className="text-sm text-slate-500 font-mono">No capabilities registered</span>
)}
</div>
</CardContent>
</Card>

{/* Badges */}
<Card className="bg-slate-950/80 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="font-mono uppercase tracking-wider text-sm text-slate-300">Earned Badges</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{badges.length > 0 ? badges.map((badge, i) => (
<div key={i} className={`flex flex-col items-center justify-center p-3 rounded-lg border ${badge.rarity === 'legendary' ? 'border-purple-500/50 bg-purple-500/10 text-purple-300' : badge.rarity === 'rare' ? 'border-blue-500/50 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-800/50 text-slate-300'}`}>

Check warning on line 248 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXacxpqeD2E2G6i4j&open=AZ8JXacxpqeD2E2G6i4j&pullRequest=369

Check warning on line 248 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXacxpqeD2E2G6i4k&open=AZ8JXacxpqeD2E2G6i4k&pullRequest=369
<span className="font-pixel text-xs text-center leading-tight">{badge.name}</span>
</div>
)) : (
<div className="col-span-2 sm:col-span-3 text-center p-4">
<span className="text-sm text-slate-500 font-mono">No badges earned yet</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>

<Image
src={getAgentOgPath(agent)}
alt={`${agent.name} Open Stellar share card`}
width={AGENT_OG_SIZE.width}
height={AGENT_OG_SIZE.height}
priority
unoptimized
className="w-full rounded-xl border border-slate-800 bg-slate-900"
/>
</section>
{/* Active Quests (Sidebar) */}
<div className="flex flex-col gap-4">
<h3 className="font-mono uppercase tracking-wider text-sm text-slate-300 mb-1 md:ml-1">Active Quests</h3>
{quests.length > 0 ? quests.slice(0, 3).map((quest, i) => (
<Card key={i} className="bg-slate-950/80 border-slate-800 overflow-hidden relative">

Check warning on line 265 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8JXacxpqeD2E2G6i4l&open=AZ8JXacxpqeD2E2G6i4l&pullRequest=369
<div className="absolute top-0 left-0 w-full h-1 bg-slate-800">
<div className="h-full bg-cyan-400" style={{ width: `${quest.progress || 0}%` }}></div>
</div>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-cyan-300 leading-tight">{quest.title}</CardTitle>
<CardDescription className="text-xs text-slate-400 line-clamp-2 mt-1">{quest.description}</CardDescription>
</CardHeader>
<CardContent className="pt-0 px-4 pb-4">
<div className="flex justify-between items-center text-xs font-mono mt-2 pt-2 border-t border-slate-800/50">
<span className="text-slate-500">{quest.progress || 0}%</span>
<span className="text-amber-400/80">+{quest.reward?.xp || 0} XP</span>
</div>
</CardContent>
</Card>
)) : (
<Card className="bg-slate-950/80 border-slate-800 border-dashed">
<CardContent className="p-6 text-center">
<span className="text-sm text-slate-500 font-mono">No active quests</span>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</main>
)
}

20 changes: 20 additions & 0 deletions app/api/agents/[id]/quest-recommendations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
import { getQuests } from "@/lib/gamification/quests"

interface RouteContext {
params: Promise<{ id: string }>
}

export async function GET(_req: Request, context: RouteContext) {
const { id } = await context.params

// Return up to 5 recommended (in progress) quests
const quests = getQuests()
.filter(q => q.status === "in_progress")
.slice(0, 5)

return NextResponse.json(
{ ok: true, quests, agentId: decodeURIComponent(id) },
{ headers: { "Cache-Control": "no-store" } },
)
}
2 changes: 1 addition & 1 deletion app/api/agents/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ export async function GET(req: Request) {
{ status: 500 },
)
}
}
}
Loading
Loading