From 1246eee505f783cd7298c5b5a5e27f163066f21e Mon Sep 17 00:00:00 2001 From: Jahnik Date: Wed, 10 Dec 2025 13:06:23 -0500 Subject: [PATCH 1/6] fixed server entrypoint for prod --- src/server/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index ecdbf8c..406ec2e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,7 +5,6 @@ import express from 'express'; import cors from 'cors'; -import ViteExpress from 'vite-express'; import { config, isProduction } from './config.js'; import { wellKnownRouter } from './oauth/wellknown.js'; import { handleDynamicClientRegistration } from './oauth/dcr.js'; @@ -152,7 +151,9 @@ if (isProduction) { `); }); } else { - // Development: use vite-express for HMR + // Development: use vite-express for HMR (dynamic import to avoid requiring it in production) + const ViteExpress = (await import('vite-express')).default; + ViteExpress.config({ mode: 'development', viteConfigFile: path.join(process.cwd(), 'src/client/vite.config.ts'), From d6ba709b303b443a80e3486eca4d8e7519a1b2c1 Mon Sep 17 00:00:00 2001 From: Jahnik Date: Wed, 10 Dec 2025 14:49:39 -0500 Subject: [PATCH 2/6] Revert "fixed server entrypoint for prod" This reverts commit 1246eee505f783cd7298c5b5a5e27f163066f21e. pushed to the wrong branch --- src/server/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 406ec2e..ecdbf8c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import express from 'express'; import cors from 'cors'; +import ViteExpress from 'vite-express'; import { config, isProduction } from './config.js'; import { wellKnownRouter } from './oauth/wellknown.js'; import { handleDynamicClientRegistration } from './oauth/dcr.js'; @@ -151,9 +152,7 @@ if (isProduction) { `); }); } else { - // Development: use vite-express for HMR (dynamic import to avoid requiring it in production) - const ViteExpress = (await import('vite-express')).default; - + // Development: use vite-express for HMR ViteExpress.config({ mode: 'development', viteConfigFile: path.join(process.cwd(), 'src/client/vite.config.ts'), From 2fcf9970488eadb0305297b61f4545fdd54be7cd Mon Sep 17 00:00:00 2001 From: Jahnik Date: Thu, 11 Dec 2025 19:45:08 -0500 Subject: [PATCH 3/6] added server entrypoint for prod, made sure logging shows up, fixed prod bug with privy app id env variable, and fixed dockerfile --- Dockerfile | 8 ++++---- src/client/vite.config.ts | 2 ++ src/server/index.ts | 12 +++++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 996993a..60f3b2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,8 @@ RUN bun install --production --frozen-lockfile # Copy built files from builder stage COPY --from=builder /app/dist ./dist -# Copy any other necessary files -COPY --from=builder /app/README.md ./README.md +# Copy public assets (favicon, etc.) +COPY --from=builder /app/public ./public # Set environment variables ENV NODE_ENV=production @@ -43,7 +43,7 @@ EXPOSE 3002 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD bun run -e "fetch('http://localhost:3002/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + CMD bun -e "fetch('http://localhost:3002/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" # Run the server -CMD ["bun", "run", "dist/server/index.js"] +CMD ["bun", "run", "start"] diff --git a/src/client/vite.config.ts b/src/client/vite.config.ts index fc0612a..fb358a7 100644 --- a/src/client/vite.config.ts +++ b/src/client/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import type { Connect } from 'vite'; +import path from 'path'; // Middleware to disable Vite's host check for ngrok/tunnel support const disableHostCheckMiddleware: Connect.NextHandleFunction = (_req, _res, next) => { @@ -20,6 +21,7 @@ export default defineConfig({ }, ], root: 'src/client', + envDir: path.resolve(__dirname, '../..'), // <- tell Vite to use repo root .env build: { outDir: '../../dist/client', emptyOutDir: true, diff --git a/src/server/index.ts b/src/server/index.ts index ecdbf8c..241d5cd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,7 +5,6 @@ import express from 'express'; import cors from 'cors'; -import ViteExpress from 'vite-express'; import { config, isProduction } from './config.js'; import { wellKnownRouter } from './oauth/wellknown.js'; import { handleDynamicClientRegistration } from './oauth/dcr.js'; @@ -120,12 +119,17 @@ if (isProduction) { const clientPath = path.join(process.cwd(), 'dist/client'); app.use(express.static(clientPath)); + // Serve OAuth UI for GET /authorize after validation passes (authorizeRouter calls next()) + // This catches the request after authorizeRouter validates params and logs authorize_request + app.get('/authorize', (_req, res) => { + res.sendFile(path.join(clientPath, 'index.html')); + }); + // Catch-all for client-side routing (after all API routes) app.get('*', (req, res) => { // Don't serve index.html for API routes if ( req.path.startsWith('/mcp') || - req.path.startsWith('/authorize') || req.path.startsWith('/token') || req.path.startsWith('/.well-known') || req.path.startsWith('/api') @@ -152,7 +156,9 @@ if (isProduction) { `); }); } else { - // Development: use vite-express for HMR + // Development: use vite-express for HMR (dynamic import to avoid requiring it in production) + const ViteExpress = (await import('vite-express')).default; + ViteExpress.config({ mode: 'development', viteConfigFile: path.join(process.cwd(), 'src/client/vite.config.ts'), From 2081959dd916538eb8a14da8b1775501f1802132 Mon Sep 17 00:00:00 2001 From: seref <1573640+serefyarar@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:34:11 -0500 Subject: [PATCH 4/6] Add GitHub Actions workflow for MCP build and deploy Introduces a CI/CD workflow to build, tag, and push Docker images to Amazon ECR and deploy to Kubernetes on push to main or dev branches. The workflow sets up required tools, configures AWS credentials, and automates deployment based on the branch. --- .github/workflows/build-mcp.yaml | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/build-mcp.yaml diff --git a/.github/workflows/build-mcp.yaml b/.github/workflows/build-mcp.yaml new file mode 100644 index 0000000..a998642 --- /dev/null +++ b/.github/workflows/build-mcp.yaml @@ -0,0 +1,68 @@ +name: MCP Build & Deploy +on: + push: + branches: + - main + - dev +jobs: + main: + runs-on: ubuntu-latest + environment: + name: ${{ github.ref == 'refs/heads/main' && 'mainnet' || 'dev' }} + steps: + - name: Install kubectl + uses: azure/setup-kubectl@v2.0 + with: + version: "v1.23.6" + id: install + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Set kubectl context + uses: azure/k8s-set-context@v3 + with: + method: kubeconfig + kubeconfig: ${{ secrets.KUBECONFIG }} + context: microk8s + + - name: Check k8s connection + run: kubectl get pods + + - name: Store build time + id: build-time + shell: bash + run: >- + echo "::set-output name=time::$(date +%s)" + + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Build, tag, and push image to Amazon ECR + env: + DOCKER_TAG: indexnetwork/mcp:${{ steps.build-time.outputs.time }} + DOCKER_REGISTRY: 236785930124.dkr.ecr.us-east-1.amazonaws.com + run: | + docker build -t $DOCKER_TAG . + docker tag $DOCKER_TAG $DOCKER_REGISTRY/$DOCKER_TAG + docker push $DOCKER_REGISTRY/$DOCKER_TAG + docker tag $DOCKER_TAG $DOCKER_REGISTRY/indexnetwork/mcp:latest-${GITHUB_REF#refs/heads/} + docker push $DOCKER_REGISTRY/indexnetwork/mcp:latest-${GITHUB_REF#refs/heads/} + + - name: Deploy + run: |- + kubectl set image deployment/mcp mcp=236785930124.dkr.ecr.us-east-1.amazonaws.com/indexnetwork/mcp:${{ steps.build-time.outputs.time }} --namespace env-${{ github.ref == 'refs/heads/main' && 'mainnet' || github.ref_name }} From 43dafa0b9d3d58fb4340984612f1fc75499bf976 Mon Sep 17 00:00:00 2001 From: seref <1573640+serefyarar@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:53:09 -0500 Subject: [PATCH 5/6] Add VITE_PRIVY_APP_ID build arg to Docker build Updates the Dockerfile and GitHub Actions workflow to accept and set the VITE_PRIVY_APP_ID build argument as an environment variable. This enables passing sensitive configuration securely during the build process. --- .github/workflows/build-mcp.yaml | 2 +- Dockerfile | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-mcp.yaml b/.github/workflows/build-mcp.yaml index a998642..4f957f4 100644 --- a/.github/workflows/build-mcp.yaml +++ b/.github/workflows/build-mcp.yaml @@ -57,7 +57,7 @@ jobs: DOCKER_TAG: indexnetwork/mcp:${{ steps.build-time.outputs.time }} DOCKER_REGISTRY: 236785930124.dkr.ecr.us-east-1.amazonaws.com run: | - docker build -t $DOCKER_TAG . + docker build --build-arg VITE_PRIVY_APP_ID=${{ secrets.VITE_PRIVY_APP_ID }} -t $DOCKER_TAG . docker tag $DOCKER_TAG $DOCKER_REGISTRY/$DOCKER_TAG docker push $DOCKER_REGISTRY/$DOCKER_TAG docker tag $DOCKER_TAG $DOCKER_REGISTRY/indexnetwork/mcp:latest-${GITHUB_REF#refs/heads/} diff --git a/Dockerfile b/Dockerfile index 60f3b2a..d5e17c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,12 @@ RUN bun install --frozen-lockfile # Copy source code COPY . . +# Accept build arguments +ARG VITE_PRIVY_APP_ID + +# Set environment variables from build arguments +ENV VITE_PRIVY_APP_ID=$VITE_PRIVY_APP_ID + # Build everything (client, widgets, server) RUN bun run build From 66945c68a8c29a7d4a803224b1fc3448af5ecf90 Mon Sep 17 00:00:00 2001 From: Jahnik Date: Thu, 18 Dec 2025 11:44:37 -0500 Subject: [PATCH 6/6] fixed connections so multiple show up --- src/server/config.ts | 9 +- src/server/mcp/discoverConnections.ts | 176 ++++++++---- .../flows/flow_discover_connections.spec.ts | 75 +++++ tests/e2e/auth/helpers/fake-protocol-api.ts | 59 ++++ tests/unit/discoverConnections.test.ts | 268 +++++++++++++++--- 5 files changed, 496 insertions(+), 91 deletions(-) diff --git a/src/server/config.ts b/src/server/config.ts index 7380f63..78c7215 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -80,10 +80,13 @@ export const config = { }, // Discover connections polling configuration + // Uses accumulate + stability strategy: keeps polling until results stabilize or limits hit discoverFilter: { - maxAttempts: Number(process.env.DISCOVER_FILTER_MAX_ATTEMPTS ?? '6'), - initialDelayMs: Number(process.env.DISCOVER_FILTER_INITIAL_DELAY_MS ?? '2000'), - maxTotalWaitMs: Number(process.env.DISCOVER_FILTER_MAX_TOTAL_WAIT_MS ?? '30000'), + maxAttempts: Number(process.env.DISCOVER_FILTER_MAX_ATTEMPTS ?? '8'), + baseDelayMs: Number(process.env.DISCOVER_FILTER_BASE_DELAY_MS ?? '300'), + delayStepMs: Number(process.env.DISCOVER_FILTER_DELAY_STEP_MS ?? '200'), + stableThreshold: Number(process.env.DISCOVER_FILTER_STABLE_THRESHOLD ?? '2'), + maxTotalWaitMs: Number(process.env.DISCOVER_FILTER_MAX_TOTAL_WAIT_MS ?? '5000'), }, // Auth storage configuration diff --git a/src/server/mcp/discoverConnections.ts b/src/server/mcp/discoverConnections.ts index 6a0ef04..2d6aa9a 100644 --- a/src/server/mcp/discoverConnections.ts +++ b/src/server/mcp/discoverConnections.ts @@ -10,7 +10,7 @@ import { callVibecheck, PrivyTokenExpiredError, type DiscoverNewIntent, - type VibecheckResponse, + type DiscoverFilterResultItem, } from '../protocol/client.js'; import { config } from '../config.js'; @@ -136,6 +136,115 @@ async function runVibechecksWithPool( return results; } +// ============================================================================= +// Polling Helper: Accumulate + Stability Strategy +// ============================================================================= + +interface PollDiscoverFilterOptions { + privyToken: string; + intentIds: string[]; + maxConnections: number; +} + +/** + * Poll discover/filter with accumulate + stability strategy. + * + * Instead of stopping on first non-empty response, this: + * 1. Accumulates unique connections across multiple polls (by user.id) + * 2. Stops when: maxConnections reached OR results stabilize OR limits hit + * + * "Stable" means the connection count hasn't changed for `stableThreshold` consecutive polls. + */ +async function pollDiscoverFilterWithAccumulation( + opts: PollDiscoverFilterOptions +): Promise { + const { privyToken, intentIds, maxConnections } = opts; + const { maxAttempts, baseDelayMs, delayStepMs, stableThreshold, maxTotalWaitMs } = config.discoverFilter; + + // Accumulate connections by user.id to dedupe across polls + const seenByUserId = new Map(); + let lastCount = 0; + let stableAttempts = 0; + const startTime = Date.now(); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Check total time limit + const elapsed = Date.now() - startTime; + if (elapsed >= maxTotalWaitMs) { + console.log(`[pollDiscoverFilter] Max total wait (${maxTotalWaitMs}ms) exceeded after ${attempt - 1} attempts`); + break; + } + + // Linear backoff delay: baseDelayMs + delayStepMs * (attempt - 1) + const delayMs = Math.min(baseDelayMs + delayStepMs * (attempt - 1), maxTotalWaitMs - elapsed); + if (delayMs > 0) { + console.log(`[discoverConnectionsFromText] Attempt ${attempt}/${maxAttempts}: waiting ${delayMs}ms before calling discover/filter`); + await delay(delayMs); + } + + try { + const filterResponse = await callDiscoverFilter(privyToken, { + intentIds, + excludeDiscovered: true, + page: 1, + limit: Math.max(maxConnections, 50), // Request at least 50 to catch more results + }); + + // Accumulate new connections + for (const result of filterResponse.results) { + const key = result.user.id; + if (!seenByUserId.has(key)) { + seenByUserId.set(key, result); + console.log(`[pollDiscoverFilter] Attempt ${attempt}: added new connection ${key} (total: ${seenByUserId.size})`); + } + } + + // Early exit if we hit maxConnections + if (seenByUserId.size >= maxConnections) { + console.log(`[pollDiscoverFilter] Reached maxConnections (${maxConnections}) on attempt ${attempt}`); + break; + } + + // Check stability + const currentCount = seenByUserId.size; + + if (currentCount === 0) { + // No results yet - keep polling without stability check + console.log(`[discoverConnectionsFromText] Attempt ${attempt}: no results yet, will retry`); + } else { + // We have some results - check if stable + if (currentCount === lastCount) { + stableAttempts++; + console.log(`[pollDiscoverFilter] Attempt ${attempt}: count stable at ${currentCount} (stable for ${stableAttempts}/${stableThreshold})`); + } else { + // Results changed, reset stability counter + stableAttempts = 0; + } + + // Stop if stable for enough consecutive polls + if (stableAttempts >= stableThreshold) { + console.log(`[pollDiscoverFilter] Results stable after ${attempt} attempts, stopping`); + break; + } + + lastCount = currentCount; + } + } catch (error) { + // Re-throw auth errors - don't continue polling + if (error instanceof PrivyTokenExpiredError) { + throw error; + } + console.error(`[discoverConnectionsFromText] Attempt ${attempt} failed:`, error); + // Continue polling on transient errors + } + } + + // Return accumulated connections, limited to maxConnections + const accumulated = Array.from(seenByUserId.values()); + console.log(`[discoverConnectionsFromText] Found ${accumulated.length} connection(s) after polling`); + return accumulated.slice(0, maxConnections); +} + // ============================================================================= // Main Orchestrator Function // ============================================================================= @@ -146,7 +255,7 @@ async function runVibechecksWithPool( * Flow: * 1. Exchange OAuth token for Privy token * 2. Call discover/new to extract intents - * 3. Call discover/filter to find matching users + * 3. Poll discover/filter to find matching users (accumulate + stability) * 4. Run vibechecks for each user with bounded concurrency * 5. Return connections formatted for widget */ @@ -175,67 +284,26 @@ export async function discoverConnectionsFromText( return { connections: [], intents: [] }; } - // Step C: Call discover/filter with bounded polling + // Step C: Poll discover/filter with accumulate + stability strategy // The Protocol API has eventual consistency - intents are written synchronously - // but indexing happens in a background queue. We poll until we get results - // or hit our configured limits. - const limit = Math.min(opts.maxConnections, 100); + // but indexing happens in a background queue. We poll and accumulate results + // until they stabilize or we hit our configured limits. const intentIds = intents.map(i => i.id); - const { maxAttempts, initialDelayMs, maxTotalWaitMs } = config.discoverFilter; - const startTime = Date.now(); - let attempt = 0; - let filterResponse: Awaited> | null = null; - - while (attempt < maxAttempts) { - const elapsed = Date.now() - startTime; - if (elapsed >= maxTotalWaitMs) { - console.log(`[discoverConnectionsFromText] Max total wait time (${maxTotalWaitMs}ms) exceeded after ${attempt} attempts`); - break; - } - - // Wait before each attempt (including the first one, to give indexer time) - const delayMs = Math.min(initialDelayMs * (attempt + 1), maxTotalWaitMs - elapsed); - if (delayMs > 0) { - console.log(`[discoverConnectionsFromText] Attempt ${attempt + 1}/${maxAttempts}: waiting ${delayMs}ms before calling discover/filter`); - await delay(delayMs); - } - - attempt++; - - try { - filterResponse = await callDiscoverFilter(privyToken, { - intentIds, - excludeDiscovered: true, - page: 1, - limit, - }); - - // If we got results, we're done polling - if (filterResponse.results.length > 0) { - console.log(`[discoverConnectionsFromText] Found ${filterResponse.results.length} connection(s) on attempt ${attempt}`); - break; - } - - console.log(`[discoverConnectionsFromText] Attempt ${attempt}: no results yet, will retry`); - } catch (error) { - // Re-throw auth errors - don't continue polling - if (error instanceof PrivyTokenExpiredError) { - throw error; - } - console.error(`[discoverConnectionsFromText] Attempt ${attempt} failed:`, error); - // Continue polling on transient errors - } - } + const filterResults = await pollDiscoverFilterWithAccumulation({ + privyToken, + intentIds, + maxConnections: opts.maxConnections, + }); // If no results after polling, return empty - if (!filterResponse || filterResponse.results.length === 0) { + if (filterResults.length === 0) { console.log('[discoverConnectionsFromText] No connections found after polling, returning with intents only'); return { connections: [], intents }; } // Step D: Run vibechecks with bounded concurrency - const vibecheckTasks: VibecheckTask[] = filterResponse.results.map(result => ({ + const vibecheckTasks: VibecheckTask[] = filterResults.map(result => ({ userId: result.user.id, intentIds, characterLimit: opts.characterLimit, @@ -248,7 +316,7 @@ export async function discoverConnectionsFromText( ); // Step E: Build ConnectionForWidget array - const connections: ConnectionForWidget[] = filterResponse.results.map(result => { + const connections: ConnectionForWidget[] = filterResults.map(result => { const vibecheck = vibecheckResults.get(result.user.id); return { diff --git a/tests/e2e/auth/flows/flow_discover_connections.spec.ts b/tests/e2e/auth/flows/flow_discover_connections.spec.ts index 32667d6..a7f1aac 100644 --- a/tests/e2e/auth/flows/flow_discover_connections.spec.ts +++ b/tests/e2e/auth/flows/flow_discover_connections.spec.ts @@ -11,6 +11,9 @@ import { setRouteError, setRouteHandler, getLastDiscoverFilterBody, + getDiscoverFilterCallCount, + setupIncrementalDiscoverFilter, + resetRoutes, } from '../helpers/index.js'; describe('Flow: Discover Connections Tool', () => { @@ -380,4 +383,76 @@ describe('Flow: Discover Connections Tool', () => { expect(result.body.result.content[0].text).toContain('Invalid input'); }); }); + + describe('Accumulation behavior', () => { + // Note: This test is skipped because the test server (server-bootstrap.ts) uses + // a simplified mock implementation that doesn't include the accumulation polling logic. + // The accumulation behavior is thoroughly tested in unit tests (discoverConnections.test.ts). + it.skip('accumulates connections across multiple discover/filter polls', async () => { + // Reset routes to clear any previous state (including call counters) + resetRoutes(); + // Set up discover/new to return intents + setRouteResponse('/discover/new', { + intents: [ + { + id: 'accumulation-intent-1', + payload: 'Test intent for accumulation', + summary: 'Accumulation test', + createdAt: new Date().toISOString(), + }, + ], + filesProcessed: 0, + linksProcessed: 0, + intentsGenerated: 1, + }); + + // Set up incremental discover/filter responses: + // Call 1: empty, Call 2: user-a, Call 3+: user-a + user-b + setupIncrementalDiscoverFilter(); + + // Set up vibecheck to return synthesis for both users + setRouteHandler('/synthesis/vibecheck', async (_req, body) => { + const data = JSON.parse(body); + const syntheses: Record = { + 'incremental-user-a': 'User A is great for collaboration on projects.', + 'incremental-user-b': 'User B has expertise in systems design.', + }; + return { + synthesis: syntheses[data.targetUserId] || 'Default synthesis', + targetUserId: data.targetUserId, + contextUserId: 'context-user', + }; + }); + + const { accessToken } = await runFullOauthFlow(); + + const result = await callMcpWithAccessToken(accessToken, 'discover_connections', { + fullInputText: 'Test query for accumulation behavior', + }); + + // Should succeed + expect(result.status).toBe(200); + expect(result.body.error).toBeUndefined(); + expect(result.body.result.isError).toBeUndefined(); + + // Should have accumulated BOTH users (not just the first one found) + const connections = result.body.result.structuredContent.connections; + expect(connections.length).toBe(2); + + // Verify both users are present + const userIds = connections.map((c: any) => c.user.id); + expect(userIds).toContain('incremental-user-a'); + expect(userIds).toContain('incremental-user-b'); + + // Verify syntheses were generated for both + const userA = connections.find((c: any) => c.user.id === 'incremental-user-a'); + const userB = connections.find((c: any) => c.user.id === 'incremental-user-b'); + expect(userA.synthesis).toContain('collaboration'); + expect(userB.synthesis).toContain('systems design'); + + // Verify polling happened multiple times (at least 3: empty + user-a + user-a+b) + const filterCallCount = getDiscoverFilterCallCount(); + expect(filterCallCount).toBeGreaterThanOrEqual(3); + }); + }); }); diff --git a/tests/e2e/auth/helpers/fake-protocol-api.ts b/tests/e2e/auth/helpers/fake-protocol-api.ts index 85dfcb2..b00b8d1 100644 --- a/tests/e2e/auth/helpers/fake-protocol-api.ts +++ b/tests/e2e/auth/helpers/fake-protocol-api.ts @@ -17,6 +17,7 @@ let server: Server | null = null; let serverPort: number = 0; const routes = new Map(); let lastDiscoverFilterBody: any | null = null; +let discoverFilterCallCount = 0; /** * Start the fake Protocol API server @@ -109,12 +110,70 @@ export function getLastDiscoverFilterBody(): any | null { return lastDiscoverFilterBody; } +/** + * Get the number of times /discover/filter has been called + */ +export function getDiscoverFilterCallCount(): number { + return discoverFilterCallCount; +} + +/** + * Set up incremental /discover/filter responses for testing accumulation behavior. + * Call 1: empty, Call 2: user-a only, Call 3+: user-a + user-b + */ +export function setupIncrementalDiscoverFilter(): void { + setRouteHandler('/discover/filter', async () => { + discoverFilterCallCount++; + const callNum = discoverFilterCallCount; + + const baseResponse = { + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }; + + if (callNum === 1) { + // First call: empty results (indexer not ready) + return { ...baseResponse, results: [] }; + } else if (callNum === 2) { + // Second call: partial results (user-a only) + return { + ...baseResponse, + results: [ + { + user: { id: 'incremental-user-a', name: 'Incremental User A', email: null, avatar: null, intro: null }, + totalStake: 100, + intents: [], + }, + ], + }; + } else { + // Third+ calls: full results (user-a + user-b) + return { + ...baseResponse, + results: [ + { + user: { id: 'incremental-user-a', name: 'Incremental User A', email: null, avatar: null, intro: null }, + totalStake: 100, + intents: [], + }, + { + user: { id: 'incremental-user-b', name: 'Incremental User B', email: null, avatar: null, intro: null }, + totalStake: 80, + intents: [], + }, + ], + }; + } + }); +} + /** * Reset all route configurations */ export function resetRoutes(): void { routes.clear(); lastDiscoverFilterBody = null; + discoverFilterCallCount = 0; // Set up default successful response for /discover/new setRouteResponse('/discover/new', { diff --git a/tests/unit/discoverConnections.test.ts b/tests/unit/discoverConnections.test.ts index 58b61da..91ae8a0 100644 --- a/tests/unit/discoverConnections.test.ts +++ b/tests/unit/discoverConnections.test.ts @@ -32,9 +32,11 @@ vi.mock('../../src/server/config.js', () => ({ instructionCharLimit: 2000, }, discoverFilter: { - maxAttempts: 3, - initialDelayMs: 10, // Fast for tests - maxTotalWaitMs: 100, // Fast for tests + maxAttempts: 6, + baseDelayMs: 5, // Fast for tests + delayStepMs: 5, // Fast for tests + stableThreshold: 2, + maxTotalWaitMs: 500, // Fast for tests }, }, })); @@ -125,12 +127,11 @@ describe('discoverConnectionsFromText', () => { // Verify callDiscoverFilter was called with correct intentIds (at least once due to polling) expect(mockCallDiscoverFilter).toHaveBeenCalledWith( 'privy-token-123', - { + expect.objectContaining({ intentIds: ['intent-1', 'intent-2'], excludeDiscovered: true, page: 1, - limit: 10, - }, + }), ); // Verify @@ -197,12 +198,11 @@ describe('discoverConnectionsFromText', () => { expect(mockCallDiscoverFilter).toHaveBeenCalledWith( 'privy-token-xyz', - { + expect.objectContaining({ intentIds: ['foo', 'bar'], excludeDiscovered: true, page: 1, - limit: 10, - }, + }), ); }); }); @@ -375,24 +375,26 @@ describe('discoverConnectionsFromText', () => { intentsGenerated: 1, }); - // First call returns empty (indexer not ready), second call returns results + // First call returns empty, subsequent calls return same result (for stability) + const userAResult = { + results: [ + { + user: { id: 'user-1', name: 'Alice', email: null, avatar: null, intro: null }, + totalStake: 100, + intents: [{ intent: { id: 'intent-1', payload: 'Test', createdAt: '2024-01-01T00:00:00Z' }, totalStake: 100, reasonings: [] }], + }, + ], + pagination: { page: 1, limit: 10, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }; + mockCallDiscoverFilter .mockResolvedValueOnce({ results: [], pagination: { page: 1, limit: 10, hasNext: false, hasPrev: false }, filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, }) - .mockResolvedValueOnce({ - results: [ - { - user: { id: 'user-1', name: 'Alice', email: null, avatar: null, intro: null }, - totalStake: 100, - intents: [{ intent: { id: 'intent-1', payload: 'Test', createdAt: '2024-01-01T00:00:00Z' }, totalStake: 100, reasonings: [] }], - }, - ], - pagination: { page: 1, limit: 10, hasNext: false, hasPrev: false }, - filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, - }); + .mockResolvedValue(userAResult); // Return same result for stability mockCallVibecheck.mockResolvedValue({ synthesis: 'Alice synthesis', @@ -406,9 +408,9 @@ describe('discoverConnectionsFromText', () => { maxConnections: 10, }); - // Should have polled twice - expect(mockCallDiscoverFilter).toHaveBeenCalledTimes(2); - // Should return the connection from second attempt + // Should have polled at least twice (1 empty + stableThreshold stable polls) + expect(mockCallDiscoverFilter.mock.calls.length).toBeGreaterThanOrEqual(2); + // Should return the connection expect(result.connections.length).toBe(1); expect(result.connections[0].user.name).toBe('Alice'); expect(result.connections[0].synthesis).toBe('Alice synthesis'); @@ -452,36 +454,234 @@ describe('discoverConnectionsFromText', () => { intentsGenerated: 1, }); - // First call fails, second call succeeds with results + // First call fails, subsequent calls succeed with results (for stability) + const userAResult = { + results: [ + { + user: { id: 'user-1', name: 'Alice', email: null, avatar: null, intro: null }, + totalStake: 100, + intents: [], + }, + ], + pagination: { page: 1, limit: 10, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }; + mockCallDiscoverFilter .mockRejectedValueOnce(new Error('Transient error')) + .mockResolvedValue(userAResult); // Return same result for stability + + mockCallVibecheck.mockResolvedValue({ + synthesis: 'Alice synthesis', + targetUserId: 'user-1', + contextUserId: 'context', + }); + + const result = await discoverConnectionsFromText({ + oauthToken: 'oauth-token', + fullInputText: 'Test input', + maxConnections: 10, + }); + + // Should have recovered and returned the connection + expect(result.connections.length).toBe(1); + expect(result.connections[0].user.name).toBe('Alice'); + }); + }); + + describe('accumulation behavior', () => { + it('accumulates connections over multiple polls', async () => { + mockCallDiscoverNew.mockResolvedValue({ + intents: [{ id: 'intent-1', payload: 'Test', createdAt: '2024-01-01T00:00:00Z' }], + filesProcessed: 0, + linksProcessed: 0, + intentsGenerated: 1, + }); + + // Simulate incremental results: empty → user-a → user-a + user-b (stable) + mockCallDiscoverFilter + .mockResolvedValueOnce({ + results: [], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }) .mockResolvedValueOnce({ results: [ - { - user: { id: 'user-1', name: 'Alice', email: null, avatar: null, intro: null }, - totalStake: 100, - intents: [], - }, + { user: { id: 'user-a', name: 'User A', email: null, avatar: null, intro: null }, totalStake: 100, intents: [] }, ], - pagination: { page: 1, limit: 10, hasNext: false, hasPrev: false }, + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }) + .mockResolvedValue({ + // Return both users - this becomes the stable result + results: [ + { user: { id: 'user-a', name: 'User A', email: null, avatar: null, intro: null }, totalStake: 100, intents: [] }, + { user: { id: 'user-b', name: 'User B', email: null, avatar: null, intro: null }, totalStake: 80, intents: [] }, + ], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, }); + mockCallVibecheck.mockImplementation(async (token: string, params: { targetUserId: string }) => ({ + synthesis: `Synthesis for ${params.targetUserId}`, + targetUserId: params.targetUserId, + contextUserId: 'context', + })); + + const result = await discoverConnectionsFromText({ + oauthToken: 'oauth-token', + fullInputText: 'Test input', + maxConnections: 10, + }); + + // Should have accumulated both users + expect(result.connections.length).toBe(2); + expect(result.connections.map(c => c.user.id)).toContain('user-a'); + expect(result.connections.map(c => c.user.id)).toContain('user-b'); + + // Vibecheck called for both + expect(mockCallVibecheck).toHaveBeenCalledTimes(2); + + // Should have polled at least 3 times (empty + user-a + user-a+b) plus stability checks + expect(mockCallDiscoverFilter.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + it('respects maxConnections cap and stops early', async () => { + mockCallDiscoverNew.mockResolvedValue({ + intents: [{ id: 'intent-1', payload: 'Test', createdAt: '2024-01-01T00:00:00Z' }], + filesProcessed: 0, + linksProcessed: 0, + intentsGenerated: 1, + }); + + // Return 3 users, but maxConnections is 1 + mockCallDiscoverFilter.mockResolvedValue({ + results: [ + { user: { id: 'user-1', name: 'User 1', email: null, avatar: null, intro: null }, totalStake: 100, intents: [] }, + { user: { id: 'user-2', name: 'User 2', email: null, avatar: null, intro: null }, totalStake: 80, intents: [] }, + { user: { id: 'user-3', name: 'User 3', email: null, avatar: null, intro: null }, totalStake: 60, intents: [] }, + ], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }); + mockCallVibecheck.mockResolvedValue({ - synthesis: 'Alice synthesis', + synthesis: 'Test synthesis', targetUserId: 'user-1', contextUserId: 'context', }); + const result = await discoverConnectionsFromText({ + oauthToken: 'oauth-token', + fullInputText: 'Test input', + maxConnections: 1, + }); + + // Should only return 1 connection + expect(result.connections.length).toBe(1); + + // Polling should stop after first non-empty result since we hit maxConnections + expect(mockCallDiscoverFilter).toHaveBeenCalledTimes(1); + + // Only 1 vibecheck call + expect(mockCallVibecheck).toHaveBeenCalledTimes(1); + }); + + it('stops when stable and under limit', async () => { + mockCallDiscoverNew.mockResolvedValue({ + intents: [{ id: 'intent-1', payload: 'Test', createdAt: '2024-01-01T00:00:00Z' }], + filesProcessed: 0, + linksProcessed: 0, + intentsGenerated: 1, + }); + + // Return same single-user result for all calls (stable from start) + const stableResult = { + results: [ + { user: { id: 'user-a', name: 'User A', email: null, avatar: null, intro: null }, totalStake: 100, intents: [] }, + ], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }; + + mockCallDiscoverFilter + .mockResolvedValueOnce({ + results: [], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }) + .mockResolvedValue(stableResult); + + mockCallVibecheck.mockResolvedValue({ + synthesis: 'User A synthesis', + targetUserId: 'user-a', + contextUserId: 'context', + }); + const result = await discoverConnectionsFromText({ oauthToken: 'oauth-token', fullInputText: 'Test input', maxConnections: 10, }); - // Should have recovered and returned the connection + // Should return the single user expect(result.connections.length).toBe(1); - expect(result.connections[0].user.name).toBe('Alice'); + expect(result.connections[0].user.id).toBe('user-a'); + + // Should stop after stability is reached (1 empty + stableThreshold+1 stable polls) + // With stableThreshold=2, we need: 1 empty + 1 with user-a (lastCount=0→1, stable=0) + // + 1 with user-a (stable=1) + 1 with user-a (stable=2, stop) + // = 4 polls total + expect(mockCallDiscoverFilter.mock.calls.length).toBeLessThanOrEqual(5); + }); + + it('deduplicates connections by user.id across polls', async () => { + mockCallDiscoverNew.mockResolvedValue({ + intents: [{ id: 'intent-1', payload: 'Test', createdAt: '2024-01-01T00:00:00Z' }], + filesProcessed: 0, + linksProcessed: 0, + intentsGenerated: 1, + }); + + // Same user returned in multiple polls should only appear once + mockCallDiscoverFilter + .mockResolvedValueOnce({ + results: [ + { user: { id: 'user-a', name: 'User A v1', email: null, avatar: 'v1.jpg', intro: null }, totalStake: 100, intents: [] }, + ], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }) + .mockResolvedValue({ + // Same user with slightly different data - should not duplicate + results: [ + { user: { id: 'user-a', name: 'User A v2', email: 'new@email.com', avatar: 'v2.jpg', intro: null }, totalStake: 150, intents: [] }, + ], + pagination: { page: 1, limit: 50, hasNext: false, hasPrev: false }, + filters: { intentIds: null, userIds: null, indexIds: null, sources: null, excludeDiscovered: true }, + }); + + mockCallVibecheck.mockResolvedValue({ + synthesis: 'User A synthesis', + targetUserId: 'user-a', + contextUserId: 'context', + }); + + const result = await discoverConnectionsFromText({ + oauthToken: 'oauth-token', + fullInputText: 'Test input', + maxConnections: 10, + }); + + // Should only have 1 connection (deduplicated by user.id) + expect(result.connections.length).toBe(1); + expect(result.connections[0].user.id).toBe('user-a'); + // Should keep the first version we saw + expect(result.connections[0].user.name).toBe('User A v1'); + expect(result.connections[0].user.avatar).toBe('v1.jpg'); + + // Only 1 vibecheck call + expect(mockCallVibecheck).toHaveBeenCalledTimes(1); }); });