diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..be2dede --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", + "changelog": [ + "@changesets/changelog-github", + { "repo": "reaatech/agent-mesh" } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.dockerignore b/.dockerignore index a8abf51..a828869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,28 +1,21 @@ # Dependencies -node_modules -npm-debug.log +node_modules/ # Build output (rebuilt in container) -dist +dist/ +.turbo/ -# Tests -tests -*.test.ts -*.spec.ts - -# Docs -docs -*.md -!package.json -!package-lock.json +# Old source (migrated to packages/) +src/ +tests/ # Git -.git +.git/ .gitignore # IDE -.idea -.vscode +.idea/ +.vscode/ *.swp *.swo @@ -36,7 +29,15 @@ Thumbs.db !.env.example # Coverage -coverage +coverage/ # Terraform -infra +infra/ + +# Docs +docs/ +*.md +!README.md + +# CI +.github/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4223eea..279881b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,80 +6,344 @@ on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 22 + jobs: + install: + name: Install Dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Save pnpm store + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Audit dependencies + run: pnpm audit --audit-level high + + format: + name: Code Format + runs-on: ubuntu-latest + needs: install + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Restore dependencies + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Check formatting + run: pnpm biome format --write . && git diff --exit-code + + lint: + name: Lint + runs-on: ubuntu-latest + needs: install + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Restore dependencies + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Lint + run: pnpm lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + needs: install + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Restore dependencies + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Type check + run: pnpm typecheck + build: + name: Build runs-on: ubuntu-latest + needs: [lint, typecheck] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - run: npm ci - - name: Build - run: npm run build + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Restore dependencies + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Re-link workspace packages + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Build packages + run: pnpm build + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: dist - path: dist/ + name: build-dist + path: | + packages/*/dist + examples/*/dist + retention-days: 1 - code-quality: + test: + name: Test runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + node-version: [20, 22] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - run: npm ci - - name: Run npm audit - run: npm audit --audit-level=high - - name: Run ESLint - run: npm run lint - - name: Run TypeScript type check - run: npm run typecheck - - name: Check formatting - run: npm run format:check + - name: Checkout + uses: actions/checkout@v4 - test: + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Restore dependencies + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Re-link workspace packages + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-dist + + - name: Run tests + run: pnpm test + + coverage: + name: Coverage runs-on: ubuntu-latest - needs: [build, code-quality] + needs: build steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - run: npm ci + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Restore dependencies + uses: actions/cache@v4 + with: + path: | + ~/.local/share/pnpm/store + node_modules + */*/node_modules + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Re-link workspace packages + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-dist + - name: Run tests with coverage - run: npm run test:coverage - - name: Upload coverage to GitHub + run: pnpm test:coverage + + - name: Upload coverage reports uses: actions/upload-artifact@v4 with: - name: coverage-report - path: coverage/ + name: coverage-reports + path: | + packages/*/coverage + retention-days: 7 + + - name: Post coverage summary + run: | + echo '## Test Coverage Summary' >> $GITHUB_STEP_SUMMARY + echo '| Package | Lines | Functions | Branches |' >> $GITHUB_STEP_SUMMARY + echo '|---------|-------|-----------|----------|' >> $GITHUB_STEP_SUMMARY + for pkg in packages/*/coverage/coverage-summary.json; do + if [ -f "$pkg" ]; then + name=$(basename $(dirname $(dirname "$pkg"))) + lines=$(jq -r '.total.lines.pct // "N/A"' "$pkg") + funcs=$(jq -r '.total.functions.pct // "N/A"' "$pkg") + branches=$(jq -r '.total.branches.pct // "N/A"' "$pkg") + echo "| $name | $lines% | $funcs% | $branches% |" >> $GITHUB_STEP_SUMMARY + fi + done docker-build: + name: Docker Build runs-on: ubuntu-latest - needs: [build, code-quality] steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker image - run: docker build -t agent-mesh:test . - - name: Check image size - run: | - SIZE=$(docker images agent-mesh:test --format "{{.Size}}") - echo "Image size: $SIZE" + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: agent-mesh:latest + cache-from: type=gha + cache-to: type=gha,mode=max - required-checks: + docker-compose: + name: Docker Compose runs-on: ubuntu-latest - needs: [build, code-quality, test, docker-build] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate Docker Compose + run: docker compose config + + all-checks: + name: All Checks Passed + runs-on: ubuntu-latest + needs: [audit, format, lint, typecheck, build, test, coverage, docker-build, docker-compose] if: always() steps: - - name: Check all required jobs - if: needs.build.result != 'success' || needs.code-quality.result != 'success' || needs.test.result != 'success' || needs.docker-build.result != 'success' - run: exit 1 + - name: Check all jobs passed + run: | + check() { + if [ "$1" != "success" ]; then + echo "$2 did not succeed: $1" + exit 1 + fi + } + check '${{ needs.audit.result }}' 'Security Audit' + check '${{ needs.format.result }}' 'Code Format' + check '${{ needs.lint.result }}' 'Lint' + check '${{ needs.typecheck.result }}' 'Type Check' + check '${{ needs.build.result }}' 'Build' + check '${{ needs.test.result }}' 'Test' + check '${{ needs.coverage.result }}' 'Coverage' + check '${{ needs.docker-build.result }}' 'Docker Build' + check '${{ needs.docker-compose.result }}' 'Docker Compose' + echo "All checks passed!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b869a7..a4df36e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,70 +1,76 @@ name: Release on: - push: - tags: - - 'v*.*.*' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: 22 jobs: - # Build and push Docker image release: + name: Release runs-on: ubuntu-latest permissions: contents: write + pull-requests: write + id-token: write packages: write steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + - name: Checkout + uses: actions/checkout@v4 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 - - name: Extract version from tag - id: get_version - run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + - name: Setup pnpm + uses: pnpm/action-setup@v4 - - name: Build and push Docker image - run: | - docker build -t ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }} . - docker push ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }} - docker tag ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }} ghcr.io/${{ github.repository }}:latest - docker push ghcr.io/${{ github.repository }}:latest - - - name: Generate changelog - id: changelog - uses: actions/github-script@v7 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - script: | - const { data: commits } = await github.rest.repos.compareCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - base: 'main', - head: context.ref - }); + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' - const changes = commits.commits.map(c => `- ${c.commit.message.split('\n')[0]}`).join('\n'); - return `## Changes\n${changes}`; + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - body: | - ## agent-mesh ${{ steps.get_version.outputs.VERSION }} + - name: Build packages + run: pnpm build - ${{ steps.changelog.outputs.result }} + - name: Create release PR or publish to npm + id: changesets + uses: changesets/action@v1 + with: + publish: pnpm release + version: pnpm version-packages + commit: 'chore(release): version packages' + title: 'chore(release): version packages' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' - ### Docker - ```bash - docker pull ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }} - ``` - draft: false - prerelease: false + - name: Mirror published packages to GitHub Packages + if: steps.changesets.outputs.published == 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + run: | + cat > .npmrc < $dir" + (cd "$dir" && npm publish --registry=https://npm.pkg.github.com) + done diff --git a/.gitignore b/.gitignore index 8b2915f..d2678c9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,15 @@ temp/ *.tfstate.backup .crashshots/ +# Stray build output in src/ (from misconfigured tsup) +packages/*/src/**/*.js +packages/*/src/**/*.js.map +packages/*/src/**/*.d.ts +packages/*/src/**/*.d.ts.map + +# Turbo cache +.turbo/ + # Docker docker-compose.override.yml diff --git a/.lintstagedrc.json b/.lintstagedrc.json deleted file mode 100644 index 553ea4f..0000000 --- a/.lintstagedrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "src/**/*.{ts,js}": [ - "eslint --fix", - "prettier --write" - ], - "tests/**/*.{ts,js}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,md,yaml,yml}": [ - "prettier --write" - ] -} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..2eccbd7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +shamefully-hoist=false +strict-peer-dependencies=true +onlyBuiltDependencies[]=esbuild diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 237762b..0000000 --- a/.prettierrc +++ /dev/null @@ -1,25 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all", - "tabWidth": 2, - "useTabs": false, - "semi": true, - "printWidth": 100, - "bracketSpacing": true, - "arrowParens": "always", - "endOfLine": "lf", - "overrides": [ - { - "files": "*.json", - "options": { - "printWidth": 120 - } - }, - { - "files": "*.yaml", - "options": { - "tabWidth": 2 - } - } - ] -} diff --git a/AGENTS.md b/AGENTS.md index 4bc4ca9..cd764bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,28 @@ and SREs deploying agent infrastructure at scale. --- +## Monorepo Structure + +agent-mesh is organized as a pnpm monorepo with 10 packages published under the +`@reaatech` scope, plus a reference deployment example. + +| Package | npm Name | Purpose | +|---------|----------|---------| +| `packages/core` | [`@reaatech/agent-mesh`](https://www.npmjs.com/package/@reaatech/agent-mesh) | Core domain types, Zod schemas, env config, constants | +| `packages/registry` | [`@reaatech/agent-mesh-registry`](https://www.npmjs.com/package/@reaatech/agent-mesh-registry) | Agent YAML loader, SIGHUP hot-reload | +| `packages/session` | [`@reaatech/agent-mesh-session`](https://www.npmjs.com/package/@reaatech/agent-mesh-session) | Firestore-backed multi-turn session management | +| `packages/classifier` | [`@reaatech/agent-mesh-classifier`](https://www.npmjs.com/package/@reaatech/agent-mesh-classifier) | Gemini Flash intent classification | +| `packages/confidence` | [`@reaatech/agent-mesh-confidence`](https://www.npmjs.com/package/@reaatech/agent-mesh-confidence) | Confidence-gated routing decision tree | +| `packages/router` | [`@reaatech/agent-mesh-router`](https://www.npmjs.com/package/@reaatech/agent-mesh-router) | MCP-based agent dispatch | +| `packages/gateway` | [`@reaatech/agent-mesh-gateway`](https://www.npmjs.com/package/@reaatech/agent-mesh-gateway) | Express middleware and request handler | +| `packages/mcp-server` | [`@reaatech/agent-mesh-mcp-server`](https://www.npmjs.com/package/@reaatech/agent-mesh-mcp-server) | MCP server exposing orchestrator | +| `packages/utils` | [`@reaatech/agent-mesh-utils`](https://www.npmjs.com/package/@reaatech/agent-mesh-utils) | Circuit breaker with Firestore persistence | +| `packages/observability` | [`@reaatech/agent-mesh-observability`](https://www.npmjs.com/package/@reaatech/agent-mesh-observability) | Logging, metrics, tracing, audit | + +**Toolchain:** pnpm workspaces + Turbo + Changesets + tsup + Biome + Vitest. + +--- + ## Architecture Overview ``` @@ -55,15 +77,18 @@ and SREs deploying agent infrastructure at scale. ### Key Components -| Component | Location | Purpose | -|-----------|----------|---------| -| **Agent Registry** | `src/registry/` | YAML agent definitions with SIGHUP hot-reload | -| **Rate Limiter** | `src/gateway/rateLimiter.middleware.ts` | Token bucket per-client rate limiting | -| **Session Manager** | `src/session/` | Firestore-backed multi-turn state | -| **Classifier** | `src/classifier/` | Gemini Flash intent classification | -| **Confidence Gate** | `src/confidence/` | Route/clarify/fallback decision tree | -| **Circuit Breaker** | `src/utils/circuitBreaker.ts` | Per-agent resilience pattern | -| **MCP Router** | `src/router/` | Agent dispatch via MCP protocol | +| Component | Package | Purpose | +|-----------|---------|---------| +| **Agent Registry** | `@reaatech/agent-mesh-registry` | YAML agent definitions with SIGHUP hot-reload | +| **Rate Limiter** | `@reaatech/agent-mesh-gateway` | Token bucket per-client rate limiting | +| **Session Manager** | `@reaatech/agent-mesh-session` | Firestore-backed multi-turn state | +| **Classifier** | `@reaatech/agent-mesh-classifier` | Gemini Flash intent classification | +| **Confidence Gate** | `@reaatech/agent-mesh-confidence` | Route/clarify/fallback decision tree | +| **Circuit Breaker** | `@reaatech/agent-mesh-utils` | Per-agent resilience pattern | +| **MCP Router** | `@reaatech/agent-mesh-router` | Agent dispatch via MCP protocol | +| **Gateway** | `@reaatech/agent-mesh-gateway` | Express middleware, entry handler, auth | +| **MCP Server** | `@reaatech/agent-mesh-mcp-server` | Exposes orchestrator as MCP-compliant agent | +| **Observability** | `@reaatech/agent-mesh-observability` | Winston logging, OTel tracing/metrics, audit | --- @@ -145,7 +170,7 @@ examples: 3. Send `SIGHUP` to the orchestrator process to hot-reload: ```bash - kill -HUP $(pgrep -f orchestrator-core) + kill -HUP $(pgrep -f orchestrator) ``` 4. Verify the agent is loaded: @@ -209,14 +234,17 @@ Agents must return a response matching this schema: } ``` -The orchestrator validates the response against this Zod schema: +The orchestrator validates the response against this Zod schema (from `@reaatech/agent-mesh`): ```typescript -const AgentResponseSchema = z.object({ - content: z.string().min(1, 'content is required'), - workflow_complete: z.boolean(), - workflow_state: z.record(z.string(), z.unknown()).optional(), -}); +import { AgentResponseSchema } from '@reaatech/agent-mesh'; + +// Schema shape: +// z.object({ +// content: z.string().min(1, 'content is required'), +// workflow_complete: z.boolean(), +// workflow_state: z.record(z.string(), z.unknown()).optional(), +// }); ``` ### Response Fields @@ -479,6 +507,8 @@ The orchestrator logs all events with `request_id` and `service` context. When building agents, follow the same pattern: ```typescript +import { logger } from '@reaatech/agent-mesh-observability'; + logger.info({ request_id: context.requestId, agent_id: 'my-agent', @@ -526,7 +556,7 @@ The orchestrator includes contract tests that validate: Run these tests to ensure your agent is compatible: ```bash -npm run test:contract +pnpm test ``` ### Agent Testing @@ -535,7 +565,7 @@ Test your agent's MCP server independently: ```typescript import { describe, it, expect } from 'vitest'; -import { handle_message } from '../src/tools/handle_message.js'; +import { AgentResponseSchema } from '@reaatech/agent-mesh'; describe('my-agent', () => { it('should handle password reset request', async () => { @@ -573,7 +603,7 @@ describe('Multi-agent routing', () => { }); const result = await response.json(); - expect(result.agentId).toBe('my-agent'); + expect(result.agent_id).toBe('my-agent'); }); }); ``` @@ -616,18 +646,29 @@ describe('Multi-agent routing', () => { | `MCP_REQUEST_TIMEOUT_MS` | no | `30000` | MCP request timeout (ms) | | `MCP_MAX_RETRIES` | no | `3` | Max retries for failed MCP requests | +### Local Development + +```bash +git clone https://github.com/reaatech/agent-mesh.git +cd agent-mesh +pnpm install +pnpm build +pnpm --filter @reaatech/agent-mesh-orchestrator build +GOOGLE_CLOUD_PROJECT=my-project API_KEY=dev-key node examples/orchestrator/dist/index.js +``` + ### Docker ```bash -docker build -t my-agent . -docker run -p 8081:8080 -e GOOGLE_CLOUD_PROJECT=my-project my-agent +docker build -t agent-mesh . +docker run -p 8080:8080 -e GOOGLE_CLOUD_PROJECT=my-project agent-mesh ``` ### GCP Cloud Run ```bash -gcloud run deploy my-agent \ - --image gcr.io/my-project/my-agent:latest \ +gcloud run deploy agent-mesh \ + --image gcr.io/my-project/agent-mesh:latest \ --platform managed \ --region us-central1 \ --allow-unauthenticated \ @@ -680,7 +721,7 @@ Before deploying an agent to production: ## References - **ARCHITECTURE.md** — Orchestrator system design deep dive -- **DEV_PLAN.md** — Development checklist for building the orchestrator - **README.md** — Quick start and overview - **MCP Specification** — https://modelcontextprotocol.io/ - **skills/** — Skill definitions for orchestrator capabilities +``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f07bc2..4cf3334 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,5 +1,47 @@ # agent-mesh — Architecture +## Monorepo Package Map + +agent-mesh is organized as a pnpm monorepo. Each package is independently publishable +under the `@reaatech` scope with dual ESM/CJS output. + +``` +packages/ +├── core/ → @reaatech/agent-mesh (types, schemas, config) +├── observability/ → @reaatech/agent-mesh-observability (logging, metrics, audit) +├── utils/ → @reaatech/agent-mesh-utils (circuit breaker, persistence) +├── registry/ → @reaatech/agent-mesh-registry (YAML loader, SIGHUP) +├── session/ → @reaatech/agent-mesh-session (Firestore session mgmt) +├── classifier/ → @reaatech/agent-mesh-classifier (Gemini intent classification) +├── confidence/ → @reaatech/agent-mesh-confidence (confidence gate, clarification) +├── router/ → @reaatech/agent-mesh-router (MCP dispatch, connection pool) +├── gateway/ → @reaatech/agent-mesh-gateway (Express middleware, handlers) +└── mcp-server/ → @reaatech/agent-mesh-mcp-server (orchestrator-as-MCP-server) +examples/ +└── orchestrator/ → Reference deployment (wires all packages together) +``` + +**Dependency graph (→ means "depends on"):** +``` +agent-mesh (core) + ├── observability ─────────────────────────────────────────────────────┐ + ├── registry ──────────────────────────────────────────────────────────┤ + ├── session ───────────────────────────────────────────────────────────┤ + ├── classifier ──► confidence ─────────────────────────────────────────┤ + ├── utils ─────────────────────────────────────────────────────────────┤ + │ ▼ + ├── session ──► gateway ──────────────────────────────────────────► mcp-server + │ │ + └───────────────────────────────────────────────────────────────────────┤ + ▼ + examples/orchestrator +``` + +**Toolchain:** pnpm workspaces + Turbo (task orchestration) + Changesets (versioning) + +tsup (dual CJS/ESM build) + Biome (lint/format) + Vitest (testing). + +--- + ## System Overview ``` @@ -13,7 +55,7 @@ │ └───────────────────┼───────────────────┘ │ │ │ HTTP/HTTPS │ └─────────────────────────────┼─────────────────────────────────────────────┘ - ▼ + ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ Gateway Layer │ │ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────┐ │ @@ -21,7 +63,7 @@ │ │Middleware│ │ Middleware │ │ Middleware │ │Middleware│ │ │ └──────────┘ └──────────────┘ └───────────────┘ └──────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ - ▼ + ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ Orchestration Core │ │ ┌──────────────────────────────────────────────────────────────────┐ │ @@ -39,7 +81,7 @@ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ - ▼ + ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ Agent Pool │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ @@ -47,7 +89,7 @@ │ │ (MCP) │ │ (MCP) │ │ (MCP) │ │ Agent │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ - ▼ + ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ Cross-Cutting Concerns │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ @@ -101,6 +143,7 @@ ### Gateway Layer The gateway handles all inbound HTTP traffic before it reaches the orchestration core. +Implemented in `@reaatech/agent-mesh-gateway`. | Middleware | Order | Purpose | |------------|-------|---------| @@ -113,9 +156,16 @@ The gateway handles all inbound HTTP traffic before it reaches the orchestration session bypass is a hard requirement — active sessions must skip classification entirely for mid-turn consistency. +**Import example:** +```typescript +import { authMiddleware, rateLimiterMiddleware, tlsMiddleware, + healthCheck, deepHealthCheck, handleRequest } from '@reaatech/agent-mesh-gateway'; +``` + ### Agent Registry -The registry is loaded from YAML files at startup and reloaded on `SIGHUP`: +The registry is loaded from YAML files at startup and reloaded on `SIGHUP`. +Implemented in `@reaatech/agent-mesh-registry`. ``` agents/ @@ -144,7 +194,8 @@ validation fails, the old registry remains active — no service disruption. ### Classifier Service -Uses Google Vertex AI Gemini Flash for intent classification: +Uses Google Vertex AI Gemini Flash for intent classification. +Implemented in `@reaatech/agent-mesh-classifier`. ``` User Input → Prompt Builder → Gemini Flash → Structured Output @@ -176,12 +227,20 @@ Output JSON: {agent_id, confidence, ambiguous, detected_language, intent_summary - Rate limit → exponential backoff with jitter - Invalid JSON → default agent with `fallback_reason: "json_parse_error"` +**Import example:** +```typescript +import { classifierService } from '@reaatech/agent-mesh-classifier'; + +const classification = await classifierService.classify(userInput, registryState.registry); +``` + **Design Decision:** Gemini Flash is used for speed and cost. The pattern works with any LLM — swap the classifier implementation, keep the orchestration. ### Confidence Gate -Evaluates classifier output against agent thresholds: +Evaluates classifier output against agent thresholds. +Implemented in `@reaatech/agent-mesh-confidence`. ``` ┌─────────────────────────────────────────────────────────────────────┐ @@ -222,17 +281,25 @@ Evaluates classifier output against agent thresholds: **Clarification Question Generation:** - Uses Gemini Flash (same model as classifier) - Localized to user's detected language -- 45+ language fallback questions pre-translated +- 58 language fallback questions pre-translated - LRU cache with 5-minute TTL - Deferred cache clear on SIGHUP (wait for active requests) +**Import example:** +```typescript +import { evaluateConfidenceGate } from '@reaatech/agent-mesh-confidence'; + +const decision = evaluateConfidenceGate(classification, registry, bypassClassifier); +``` + **Design Decision:** Clarification is generated by Gemini (not templates) because it produces more natural, context-aware questions. The fallback questions ensure users always see localized text even if Gemini fails. ### Circuit Breaker -Per-agent circuit breaker prevents cascading failures: +Per-agent circuit breaker prevents cascading failures. +Implemented in `@reaatech/agent-mesh-utils`. ``` ┌─────────────────────────────────────────────────────────────────────┐ @@ -295,6 +362,12 @@ Per-agent circuit breaker prevents cascading failures: └─────────────────────────────────────────────────────────────────────┘ ``` +**Import example:** +```typescript +import { circuitBreaker } from '@reaatech/agent-mesh-utils'; +import { startCircuitBreakerPersistence, stopCircuitBreakerPersistence } from '@reaatech/agent-mesh-utils'; +``` + **Leader Election:** - Lease-based approach with periodic renewal - Fencing tokens prevent network partition issues @@ -302,12 +375,12 @@ Per-agent circuit breaker prevents cascading failures: - Followers maintain in-memory state only **Design Decision:** Leader election adds complexity but is necessary for -cross-instance consistency without a central coordinator. The lease-based -approach handles instance failures gracefully. +cross-instance consistency without a central coordinator. ### Session Management -Firestore-backed session storage with 30-minute sliding TTL: +Firestore-backed session storage with 30-minute sliding TTL. +Implemented in `@reaatech/agent-mesh-session`. ``` ┌─────────────────────────────────────────────────────────────────────┐ @@ -336,6 +409,12 @@ Firestore-backed session storage with 30-minute sliding TTL: └─────────────────────────────────────────────────────────────────────┘ ``` +**Import example:** +```typescript +import { createSession, getActiveSession, appendTurn, + closeSession, sessionMiddleware } from '@reaatech/agent-mesh-session'; +``` + **Session Bypass Middleware:** ``` Request → Session Middleware → Active session found? @@ -359,7 +438,8 @@ re-classified. This ensures conversational consistency and reduces latency. ### MCP Router -Dispatches requests to agents via MCP StreamableHTTP: +Dispatches requests to agents via MCP StreamableHTTP. +Implemented in `@reaatech/agent-mesh-router`. ``` ┌─────────────────────────────────────────────────────────────────────┐ @@ -381,6 +461,13 @@ Dispatches requests to agents via MCP StreamableHTTP: └─────────────────────────────────────────────────────────────────────┘ ``` +**Import example:** +```typescript +import { dispatchToAgent, mcpClientFactory } from '@reaatech/agent-mesh-router'; + +const response = await dispatchToAgent(agent, { sessionId, employeeId, ... }); +``` + **Timeout Strategy:** - Default: 30 seconds - Configurable per-agent via environment variable @@ -391,6 +478,34 @@ Dispatches requests to agents via MCP StreamableHTTP: handle their own retries for transient failures. The orchestrator retries only on clearly transient errors (network timeouts, 503s). +### MCP Server Layer + +Exposes the orchestrator as an MCP-compliant agent. +Implemented in `@reaatech/agent-mesh-mcp-server`. + +Provides JSON-RPC 2.0 routing with three tools: +- `handle_message` — route user messages through the full orchestrator pipeline +- `get_session_status` — query session state by ID +- `list_agents` — enumerate all registered agents + +Also provides SSE transport for legacy MCP client compatibility. + +**Import example:** +```typescript +import { mcpMiddleware, sseHandler, messageHandler } from '@reaatech/agent-mesh-mcp-server'; +``` + +### Observability Layer + +Structured logging, metrics, and audit events. +Implemented in `@reaatech/agent-mesh-observability`. + +```typescript +import { logger, createChildLogger } from '@reaatech/agent-mesh-observability'; +import { recordAgentDispatchDuration } from '@reaatech/agent-mesh-observability'; +import { logAgentRouted, logCircuitBreakerChange } from '@reaatech/agent-mesh-observability'; +``` + --- ## Security Model @@ -435,17 +550,45 @@ Agent endpoint URLs are validated to reject: This validation runs regardless of environment (dev or prod) to catch misconfigurations early. -### Prompt-Injection Defense +--- + +## Build & Toolchain + +### Build Pipeline + +``` +pnpm install → pnpm build (turbo run build) + │ + ┌───────┴────────┐ + │ tsup per-pkg │ + │ CJS + ESM + │ + │ DTS output │ + └───────┬────────┘ + │ + packages/*/dist/ + ├── index.js (ESM) + ├── index.cjs (CJS) + ├── index.d.ts (types ESM) + └── index.d.cts (types CJS) +``` -The gateway sanitizes string inputs for known injection patterns: -- `', - '{{constructor.constructor("return this")()}}', - '${7*7}', - ]; - - for (const input of injectionAttempts) { - const response = await dispatch({ - method: 'POST', - path: '/v1/request', - headers: { 'x-api-key': 'test-api-key' }, - body: { input }, - }); - - expect(response.status).toBe(503); - } - }); - - it('should reject input with null bytes', async () => { - const response = await dispatch({ - method: 'POST', - path: '/v1/request', - headers: { 'x-api-key': 'test-api-key' }, - body: { input: 'Hello\x00World' }, - }); - - expect(response.status).toBe(503); - }); - - it('should handle unicode input correctly', async () => { - const unicodeInputs = ['你好世界', 'Привет мир', '🌍🌎🌏', 'مرحبا بالعالم', 'こんにちは世界']; - - for (const input of unicodeInputs) { - const response = await dispatch({ - method: 'POST', - path: '/v1/request', - headers: { 'x-api-key': 'test-api-key' }, - body: { input }, - }); - - expect(response.status).toBe(503); - } - }); - - it('should reject extremely large inputs', async () => { - const response = await dispatch({ - method: 'POST', - path: '/v1/request', - headers: { 'x-api-key': 'test-api-key' }, - body: { input: 'a'.repeat(1000000) }, - }); - - expect([400, 413]).toContain(response.status); - }); - }); - - describe('Header injection', () => { - it.skip('should handle headers with newlines'); - it.skip('should handle headers with null bytes'); - }); - - describe('Path traversal', () => { - it('should not expose file system paths', async () => { - const paths = [ - '/../../../etc/passwd', - '/..%2F..%2F..%2Fetc%2Fpasswd', - '/....//....//etc/passwd', - '/%2e%2e/%2e%2e/etc/passwd', - ]; - - for (const path of paths) { - const response = await dispatch({ - method: 'GET', - path, - headers: { 'x-api-key': 'test-api-key' }, - }); - - expect(response.status).toBe(404); - } - }); - }); - - describe('Method tampering', () => { - it('should reject unsupported HTTP methods', async () => { - const methods = ['PUT', 'DELETE', 'PATCH']; - - for (const method of methods) { - const response = await dispatch({ - method, - path: '/health', - headers: { 'x-api-key': 'test-api-key' }, - }); - - expect(response.status).toBe(404); - } - }); - - it('should reject POST to health endpoint', async () => { - const response = await dispatch({ - method: 'POST', - path: '/health', - headers: { 'x-api-key': 'test-api-key' }, - body: {}, - }); - - expect(response.status).toBe(404); - }); - }); - - describe('Content-Type tampering', () => { - it('should handle wrong Content-Type gracefully', async () => { - const response = await dispatch({ - method: 'POST', - path: '/v1/request', - headers: { - 'x-api-key': 'test-api-key', - 'Content-Type': 'text/plain', - }, - body: 'Hello World', - }); - - expect(response.status).toBeGreaterThanOrEqual(400); - }); - - it('should handle malformed JSON', async () => { - const response = await dispatch({ - method: 'POST', - path: '/v1/request', - headers: { - 'x-api-key': 'test-api-key', - 'Content-Type': 'application/json', - }, - rawBody: '{ invalid json }', - }); - - expect(response.status).toBe(400); - }); - }); -}); diff --git a/tests/unit/audit.test.ts b/tests/unit/audit.test.ts deleted file mode 100644 index b2da9c7..0000000 --- a/tests/unit/audit.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockInfo = vi.fn(); - -vi.mock('../../src/observability/logger.js', () => ({ - logger: { - info: mockInfo, - }, -})); - -const { - logAuditEvent, - logAuthRequest, - logAgentRouted, - logCircuitBreakerChange, - logSecurityEvent, - AUDIT_EVENTS, -} = await import('../../src/observability/audit.js'); - -describe('logAuditEvent', () => { - beforeEach(() => { - mockInfo.mockClear(); - }); - - it('logs with required fields', () => { - logAuditEvent({ - event_type: AUDIT_EVENTS.AUTH_SUCCESS, - timestamp: '2026-01-01T00:00:00Z', - }); - - expect(mockInfo).toHaveBeenCalledTimes(1); - const calls = mockInfo.mock.calls; - expect(calls.length).toBeGreaterThan(0); - const call = calls[0]!; - expect(call[0]).toContain('auth.success'); - const meta = call[1] as Record; - expect(meta.audit).toBe(true); - expect(meta.event_type).toBe('auth.success'); - }); - - it('includes optional fields when provided', () => { - logAuditEvent({ - event_type: AUDIT_EVENTS.AGENT_ROUTED, - timestamp: '2026-01-01T00:00:00Z', - request_id: 'req-123', - session_id: 'sess-456', - user_id: 'user-789', - employee_id: 'emp-012', - agent_id: 'agent-345', - outcome: 'success', - details: { key: 'value' }, - }); - - const calls = mockInfo.mock.calls; - expect(calls.length).toBeGreaterThan(0); - const meta = calls[0]![1] as Record; - expect(meta.request_id).toBe('req-123'); - expect(meta.session_id).toBe('sess-456'); - expect(meta.user_id).toBe('user-789'); - expect(meta.employee_id).toBe('emp-012'); - expect(meta.agent_id).toBe('agent-345'); - expect(meta.outcome).toBe('success'); - expect(meta.details).toEqual({ key: 'value' }); - }); - - it('uses provided timestamp', () => { - logAuditEvent({ - event_type: AUDIT_EVENTS.AUTH_FAILURE, - timestamp: '2026-06-15T12:00:00Z', - }); - - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.timestamp).toBe('2026-06-15T12:00:00Z'); - }); - - it('does not include undefined optional fields', () => { - logAuditEvent({ - event_type: AUDIT_EVENTS.SESSION_CREATED, - timestamp: '2026-01-01T00:00:00Z', - }); - - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta).not.toHaveProperty('request_id'); - expect(meta).not.toHaveProperty('session_id'); - expect(meta).not.toHaveProperty('outcome'); - expect(meta).not.toHaveProperty('failure_reason'); - expect(meta).not.toHaveProperty('details'); - }); -}); - -describe('logAuthRequest', () => { - beforeEach(() => { - mockInfo.mockClear(); - }); - - it('logs success event', () => { - logAuthRequest('req-1', 'success'); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('auth.success'); - expect(meta.outcome).toBe('success'); - }); - - it('logs failure event', () => { - logAuthRequest('req-2', 'failure'); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('auth.failure'); - expect(meta.outcome).toBe('failure'); - }); - - it('includes details when provided', () => { - logAuthRequest('req-3', 'failure', { reason: 'expired key' }); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.details).toEqual({ reason: 'expired key' }); - }); -}); - -describe('logAgentRouted', () => { - beforeEach(() => { - mockInfo.mockClear(); - }); - - it('logs routed event for non-fallback', () => { - logAgentRouted('req-1', 'sess-1', 'agent-1', 0.85, false); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('agent.routed'); - expect(meta.agent_id).toBe('agent-1'); - }); - - it('logs fallback event', () => { - logAgentRouted('req-1', undefined, 'default', 0.3, true); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('agent.fallback'); - expect(meta.details).toEqual({ confidence: 0.3, is_fallback: true }); - }); - - it('includes session_id when provided', () => { - logAgentRouted('req-1', 'sess-1', 'agent-1', 0.9, false); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.session_id).toBe('sess-1'); - }); - - it('omits session_id when undefined', () => { - logAgentRouted('req-1', undefined, 'agent-1', 0.9, false); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta).not.toHaveProperty('session_id'); - }); -}); - -describe('logCircuitBreakerChange', () => { - beforeEach(() => { - mockInfo.mockClear(); - }); - - it('logs opened event', () => { - logCircuitBreakerChange('agent-1', 'open'); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('circuit_breaker.opened'); - }); - - it('logs closed event', () => { - logCircuitBreakerChange('agent-1', 'closed'); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('circuit_breaker.closed'); - }); - - it('logs half_open event', () => { - logCircuitBreakerChange('agent-1', 'half_open'); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('circuit_breaker.half_open'); - }); - - it('includes details when provided', () => { - logCircuitBreakerChange('agent-1', 'open', { failures: 10 }); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.details).toEqual({ failures: 10 }); - }); -}); - -describe('logSecurityEvent', () => { - beforeEach(() => { - mockInfo.mockClear(); - }); - - it('logs security event with failure outcome', () => { - logSecurityEvent(AUDIT_EVENTS.SSRF_ATTEMPT, 'req-1'); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.event_type).toBe('security.ssrf_attempt'); - expect(meta.outcome).toBe('failure'); - expect(meta.request_id).toBe('req-1'); - }); - - it('includes details when provided', () => { - logSecurityEvent(AUDIT_EVENTS.PROMPT_INJECTION, 'req-2', { input: 'malicious' }); - const meta = mockInfo.mock.calls[0]![1] as Record; - expect(meta.details).toEqual({ input: 'malicious' }); - }); -}); diff --git a/tests/unit/auth.middleware.test.ts b/tests/unit/auth.middleware.test.ts deleted file mode 100644 index d540f01..0000000 --- a/tests/unit/auth.middleware.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { Request, Response, NextFunction } from 'express'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - NODE_ENV: 'test', - API_KEY: 'test-api-key', - API_KEY_SECRET_NAME: undefined, - }, -})); - -vi.mock('@google-cloud/secret-manager', () => ({ - SecretManagerServiceClient: vi.fn(), -})); - -const { authMiddleware, clearAuthCache } = await import('../../src/gateway/auth.middleware.js'); - -function mockReqRes(overrides: Partial & { headers?: Record } = {}) { - const req = { - headers: {}, - method: 'POST', - ...overrides, - } as unknown as Request & { apiKey?: string }; - - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - } as unknown as Response; - - const next = vi.fn() as NextFunction; - - return { req, res, next }; -} - -describe('authMiddleware', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearAuthCache(); - }); - - afterEach(() => { - clearAuthCache(); - }); - - describe('API key validation', () => { - it('returns 401 when x-api-key header is missing', async () => { - const { req, res, next } = mockReqRes({ headers: {} }); - - await authMiddleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Authentication required', - message: 'Missing x-api-key header', - }), - ); - expect(next).not.toHaveBeenCalled(); - }); - - it('returns 401 when API key is invalid', async () => { - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': 'invalid-key' } }); - - await authMiddleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Authentication failed', - message: 'Invalid API key', - }), - ); - expect(next).not.toHaveBeenCalled(); - }); - - it('calls next when API key is valid', async () => { - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': 'test-api-key' } }); - - await authMiddleware(req, res, next); - - expect(next).toHaveBeenCalled(); - expect(req.apiKey).toBe('test-api-key'); - }); - - it('returns 401 for empty API key string', async () => { - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': '' } }); - - await authMiddleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - - it('returns 401 for whitespace-only API key', async () => { - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': ' ' } }); - - await authMiddleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(next).not.toHaveBeenCalled(); - }); - }); - - describe('development mode bypass', () => { - it('bypasses validation when NODE_ENV is development and API_KEY is dev-key', async () => { - vi.resetModules(); - vi.doMock('../../src/config/env.js', () => ({ - env: { - NODE_ENV: 'development', - API_KEY: 'dev-key', - API_KEY_SECRET_NAME: undefined, - }, - })); - - const { authMiddleware: devAuthMiddleware } = - await import('../../src/gateway/auth.middleware.js'); - - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': 'dev-key' } }); - - await devAuthMiddleware(req, res, next); - - expect(next).toHaveBeenCalled(); - vi.resetModules(); - }); - }); - - describe('API key caching', () => { - it('caches validation results for repeated calls', async () => { - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': 'test-api-key' } }); - - await authMiddleware(req, res, next); - await authMiddleware(req, res, next); - - expect(next).toHaveBeenCalledTimes(2); - }); - }); - - describe('error handling', () => { - it('returns 503 when Secret Manager throws', async () => { - vi.resetModules(); - vi.doMock('../../src/config/env.js', () => ({ - env: { - NODE_ENV: 'test', - API_KEY: 'fallback-key', - API_KEY_SECRET_NAME: 'projects/test/secrets/api-key/versions/latest', - }, - })); - vi.doMock('@google-cloud/secret-manager', () => { - return { - SecretManagerServiceClient: vi.fn().mockImplementation(() => { - throw new Error('Secret Manager unavailable'); - }), - }; - }); - - const { authMiddleware: errorMiddleware } = - await import('../../src/gateway/auth.middleware.js'); - - const { req, res, next } = mockReqRes({ headers: { 'x-api-key': 'fallback-key' } }); - - await errorMiddleware(req, res, next); - - expect(res.status).toHaveBeenCalledWith(503); - vi.resetModules(); - }); - }); -}); - -describe('clearAuthCache', () => { - it('clears the key cache without throwing', () => { - expect(() => clearAuthCache()).not.toThrow(); - }); - - it('can be called multiple times', () => { - clearAuthCache(); - clearAuthCache(); - expect(() => clearAuthCache()).not.toThrow(); - }); -}); diff --git a/tests/unit/circuitBreaker.integration.test.ts b/tests/unit/circuitBreaker.integration.test.ts deleted file mode 100644 index 06bf272..0000000 --- a/tests/unit/circuitBreaker.integration.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const docs = new Map>(); -const leaderDocPath = 'leader_election/circuit_breaker_sync_leader'; - -function getDoc(path: string): Record | undefined { - return docs.get(path); -} - -vi.mock('../../src/config/env.js', () => ({ - env: { - CB_SYNC_INTERVAL_MS: 5000, - CB_LEADER_LEASE_MS: 15000, - CB_LEADER_RENEWAL_MS: 5000, - }, -})); - -vi.mock('../../src/session/firestoreClient.js', () => ({ - getFirestore: () => ({ - collection: (collectionName: string) => ({ - doc: (docId: string) => ({ - async set(data: Record, options?: { merge?: boolean }) { - const path = `${collectionName}/${docId}`; - const current = docs.get(path) ?? {}; - docs.set(path, options?.merge ? { ...current, ...data } : { ...data }); - }, - async get() { - const path = `${collectionName}/${docId}`; - const data = docs.get(path); - return { - id: docId, - exists: Boolean(data), - data: () => data, - }; - }, - }), - async get() { - const prefix = `${collectionName}/`; - const matched = Array.from(docs.entries()) - .filter(([path]) => path.startsWith(prefix)) - .map(([path, data]) => ({ - id: path.slice(prefix.length), - data: () => data, - })); - return { docs: matched }; - }, - }), - async runTransaction( - handler: (transaction: { - get: (docRef: { get: () => Promise }) => Promise; - set: ( - docRef: { set: (data: Record) => Promise }, - data: Record, - ) => Promise; - update: ( - docRef: { - set: (data: Record, options?: { merge?: boolean }) => Promise; - }, - data: Record, - ) => Promise; - }) => Promise, - ) { - return handler({ - get: async (docRef) => docRef.get(), - set: async (docRef, data) => docRef.set(data), - update: async (docRef, data) => docRef.set(data, { merge: true }), - }); - }, - }), -})); - -const persistence = await import('../../src/utils/circuitBreaker.persistence.js'); - -describe('Circuit breaker persistence integration', () => { - beforeEach(() => { - docs.clear(); - persistence.clearLocalState(); - persistence.resetLeaderState(); - persistence.__setInstanceIdForTests('instance-a'); - }); - - it('persists and restores state from Firestore', async () => { - await persistence.persistCircuitBreakerState({ - agent_id: 'agent-a', - state: 'OPEN', - failure_count: 5, - success_count: 0, - last_failure_time: 123, - last_state_change: 456, - half_open_calls: 0, - backoff_multiplier: 2, - }); - - persistence.clearLocalState(); - await persistence.restoreCircuitBreakerStates(1); - - expect(persistence.getLocalCircuitBreakerState('agent-a')).toEqual( - expect.objectContaining({ - agent_id: 'agent-a', - state: 'OPEN', - failure_count: 5, - }), - ); - }); - - it('elects a leader and writes the leader lease', async () => { - await persistence.startCircuitBreakerPersistence(); - - expect(persistence.isLeader()).toBe(true); - expect(getDoc(leaderDocPath)).toEqual( - expect.objectContaining({ - leader_id: 'instance-a', - }), - ); - - persistence.stopCircuitBreakerPersistence(); - }); -}); diff --git a/tests/unit/circuitBreaker.persistence.test.ts b/tests/unit/circuitBreaker.persistence.test.ts deleted file mode 100644 index c7078eb..0000000 --- a/tests/unit/circuitBreaker.persistence.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { CircuitBreakerState } from '../../src/types/domain.js'; - -const mockDocRef = { - set: vi.fn().mockResolvedValue(undefined), - get: vi.fn(), - update: vi.fn().mockResolvedValue(undefined), -}; - -const mockCollection = vi.fn().mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: vi.fn(), -}); - -const mockRunTransaction = vi.fn(); - -vi.mock('../../src/session/firestoreClient.js', () => ({ - getFirestore: () => ({ - collection: mockCollection, - runTransaction: mockRunTransaction, - }), -})); - -vi.mock('../../src/config/env.js', () => ({ - env: { - GOOGLE_CLOUD_PROJECT: 'test-project', - CB_LEADER_LEASE_MS: 15000, - CB_SYNC_INTERVAL_MS: 5000, - }, -})); - -const { - persistCircuitBreakerState, - loadCircuitBreakerState, - loadAllCircuitBreakerStates, - restoreCircuitBreakerStates, - updateCircuitBreakerState, - getLocalCircuitBreakerState, - setLocalCircuitBreakerState, - clearLocalState, - resetLeaderState, - isLeader, - getLeaderId, - __setInstanceIdForTests, - startCircuitBreakerPersistence, - stopCircuitBreakerPersistence, -} = await import('../../src/utils/circuitBreaker.persistence.js'); - -const sampleState: CircuitBreakerState = { - agent_id: 'test-agent', - state: 'CLOSED', - failure_count: 0, - success_count: 5, - last_state_change: Date.now(), - half_open_calls: 0, - backoff_multiplier: 1, -}; - -describe('persistCircuitBreakerState', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearLocalState(); - resetLeaderState(); - __setInstanceIdForTests('test-instance'); - }); - - it('persists state to Firestore', async () => { - await persistCircuitBreakerState(sampleState); - expect(mockDocRef.set).toHaveBeenCalledWith( - expect.objectContaining({ - agent_id: 'test-agent', - state: 'CLOSED', - last_synced: expect.any(Number), - synced_by: 'test-instance', - }), - { merge: true }, - ); - }); - - it('retries on retryable errors', async () => { - mockDocRef.set - .mockRejectedValueOnce(new Error('quota exceeded')) - .mockResolvedValueOnce(undefined); - - await persistCircuitBreakerState(sampleState); - expect(mockDocRef.set).toHaveBeenCalledTimes(2); - }); - - it('gives up after 3 attempts on retryable error that keeps failing', async () => { - mockDocRef.set.mockRejectedValue(new Error('quota exceeded or unavailable')); - await persistCircuitBreakerState(sampleState); - expect(mockDocRef.set).toHaveBeenCalledTimes(3); - }); - - it('gives up immediately on non-retryable error', async () => { - mockDocRef.set.mockRejectedValue(new Error('permission denied')); - await persistCircuitBreakerState(sampleState); - expect(mockDocRef.set).toHaveBeenCalledTimes(1); - }); -}); - -describe('loadCircuitBreakerState', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns state when document exists', async () => { - mockDocRef.get.mockResolvedValue({ - exists: true, - data: () => ({ ...sampleState }), - }); - - const state = await loadCircuitBreakerState('test-agent'); - expect(state).not.toBeNull(); - expect(state?.agent_id).toBe('test-agent'); - expect(state?.state).toBe('CLOSED'); - }); - - it('returns null when document does not exist', async () => { - mockDocRef.get.mockResolvedValue({ exists: false }); - const state = await loadCircuitBreakerState('nonexistent'); - expect(state).toBeNull(); - }); - - it('returns null on error', async () => { - mockDocRef.get.mockRejectedValue(new Error('firestore error')); - const state = await loadCircuitBreakerState('error-agent'); - expect(state).toBeNull(); - }); -}); - -describe('loadAllCircuitBreakerStates', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns map of all states', async () => { - const mockGet = vi.fn().mockResolvedValue({ - docs: [ - { data: () => ({ ...sampleState, agent_id: 'agent-1' }) }, - { data: () => ({ ...sampleState, agent_id: 'agent-2', state: 'OPEN' }) }, - ], - }); - - mockCollection.mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: mockGet, - }); - - const states = await loadAllCircuitBreakerStates(); - expect(states.size).toBe(2); - expect(states.get('agent-1')?.state).toBe('CLOSED'); - expect(states.get('agent-2')?.state).toBe('OPEN'); - }); -}); - -describe('restoreCircuitBreakerStates', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearLocalState(); - }); - - it('restores states from Firestore', async () => { - const mockGet = vi.fn().mockResolvedValue({ - docs: [{ data: () => ({ ...sampleState, agent_id: 'agent-1' }) }], - }); - - mockCollection.mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: mockGet, - }); - - await restoreCircuitBreakerStates(1); - const local = getLocalCircuitBreakerState('agent-1'); - expect(local).toBeDefined(); - expect(local?.agent_id).toBe('agent-1'); - }); - - it('retries on failure', async () => { - const mockGet = vi - .fn() - .mockRejectedValueOnce(new Error('transient')) - .mockResolvedValue({ docs: [] }); - - mockCollection.mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: mockGet, - }); - - await restoreCircuitBreakerStates(3); - expect(mockGet).toHaveBeenCalledTimes(2); - }); - - it('throws after max retries', async () => { - const mockGet = vi.fn().mockRejectedValue(new Error('persistent')); - - mockCollection.mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: mockGet, - }); - - await expect(restoreCircuitBreakerStates(2)).rejects.toThrow('persistent'); - }); -}); - -describe('updateCircuitBreakerState', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearLocalState(); - }); - - it('updates local state and persists', async () => { - await updateCircuitBreakerState(sampleState); - expect(getLocalCircuitBreakerState('test-agent')).toEqual(sampleState); - expect(mockDocRef.set).toHaveBeenCalled(); - }); -}); - -describe('local state management', () => { - beforeEach(() => { - clearLocalState(); - }); - - it('setLocalCircuitBreakerState sets without persistence', () => { - setLocalCircuitBreakerState(sampleState); - expect(getLocalCircuitBreakerState('test-agent')).toEqual(sampleState); - }); - - it('clearLocalState removes all entries', () => { - setLocalCircuitBreakerState(sampleState); - clearLocalState(); - expect(getLocalCircuitBreakerState('test-agent')).toBeUndefined(); - }); - - it('getLocalCircuitBreakerState returns undefined for missing', () => { - expect(getLocalCircuitBreakerState('missing')).toBeUndefined(); - }); -}); - -describe('leader election', () => { - beforeEach(() => { - resetLeaderState(); - clearLocalState(); - vi.clearAllMocks(); - __setInstanceIdForTests('test-leader-instance'); - }); - - it('isLeader returns false when no leader state', () => { - expect(isLeader()).toBe(false); - }); - - it('getLeaderId returns null when no leader state', () => { - expect(getLeaderId()).toBeNull(); - }); - - it('startCircuitBreakerPersistence becomes leader when no existing leader', async () => { - mockRunTransaction.mockImplementation(async (callback) => { - const mockDoc = { - exists: false, - data: () => null, - }; - return callback({ - get: vi.fn().mockResolvedValue(mockDoc), - set: vi.fn(), - update: vi.fn(), - }); - }); - - mockDocRef.get.mockResolvedValue({ exists: false, docs: [] }); - mockCollection.mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: vi.fn().mockResolvedValue({ docs: [] }), - }); - - await startCircuitBreakerPersistence(); - - expect(isLeader()).toBe(true); - expect(getLeaderId()).toBe('test-leader-instance'); - - stopCircuitBreakerPersistence(); - }); - - it('stopCircuitBreakerPersistence clears sync interval', () => { - stopCircuitBreakerPersistence(); - expect(isLeader()).toBe(false); - }); -}); - -describe('startCircuitBreakerPersistence', () => { - beforeEach(() => { - resetLeaderState(); - clearLocalState(); - vi.clearAllMocks(); - __setInstanceIdForTests('start-test-instance'); - }); - - afterEach(() => { - stopCircuitBreakerPersistence(); - }); - - it('initializes without throwing', async () => { - mockRunTransaction.mockImplementation(async (callback) => { - const mockDoc = { - exists: false, - data: () => null, - }; - return callback({ - get: vi.fn().mockResolvedValue(mockDoc), - set: vi.fn(), - update: vi.fn(), - }); - }); - - mockDocRef.get.mockResolvedValue({ exists: false, docs: [] }); - mockCollection.mockReturnValue({ - doc: vi.fn().mockReturnValue(mockDocRef), - get: vi.fn().mockResolvedValue({ docs: [] }), - }); - - await expect(startCircuitBreakerPersistence()).resolves.not.toThrow(); - }); -}); - -describe('stopCircuitBreakerPersistence', () => { - it('clears sync interval handle', () => { - stopCircuitBreakerPersistence(); - stopCircuitBreakerPersistence(); - }); -}); diff --git a/tests/unit/circuitBreaker.test.ts b/tests/unit/circuitBreaker.test.ts deleted file mode 100644 index 1f32ecd..0000000 --- a/tests/unit/circuitBreaker.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Circuit Breaker Unit Tests - * Test state transitions, exponential backoff, and half-open behavior - * - * Note: Tests the circuit breaker logic directly without importing - * the singleton to avoid env validation during tests. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock the env module before importing circuit breaker -vi.mock('../../src/config/env.js', () => ({ - env: { - CIRCUIT_BREAKER_FAILURE_THRESHOLD: 5, - CIRCUIT_BREAKER_RESET_TIMEOUT_MS: 30000, - CIRCUIT_BREAKER_HALF_OPEN_MAX_CALLS: 3, - CIRCUIT_BREAKER_HALF_OPEN_TIMEOUT_MS: 60000, - }, -})); - -// Now import the circuit breaker -import { circuitBreaker } from '../../src/utils/circuitBreaker.js'; - -describe('Circuit Breaker', () => { - beforeEach(() => { - circuitBreaker.clear(); - }); - - describe('State Transitions', () => { - it('should start in CLOSED state', () => { - const state = circuitBreaker.getState('test-agent'); - expect(state.state).toBe('CLOSED'); - }); - - it('should transition to OPEN after exceeding failure threshold', () => { - // Record failures up to threshold (default is 5) - for (let i = 0; i < 5; i++) { - circuitBreaker.recordFailure('test-agent'); - } - - const state = circuitBreaker.getState('test-agent'); - expect(state.state).toBe('OPEN'); - }); - - it('should stay CLOSED when failures below threshold', () => { - // Record fewer failures than threshold - for (let i = 0; i < 4; i++) { - circuitBreaker.recordFailure('test-agent'); - } - - const state = circuitBreaker.getState('test-agent'); - expect(state.state).toBe('CLOSED'); - }); - - it('should transition to CLOSED after successful HALF_OPEN calls', () => { - // Force open - circuitBreaker.forceState('test-agent', 'OPEN'); - - // Wait for reset timeout (simulate with fake timers) - vi.useFakeTimers(); - vi.advanceTimersByTime(31000); // Default reset timeout is 30s - - // Trigger half-open by calling getState - const state1 = circuitBreaker.getState('test-agent'); - expect(state1.state).toBe('HALF_OPEN'); - - // Record successful calls - for (let i = 0; i < 3; i++) { - circuitBreaker.recordSuccess('test-agent'); - } - - const state2 = circuitBreaker.getState('test-agent'); - expect(state2.state).toBe('CLOSED'); - - vi.useRealTimers(); - }); - - it('should transition back to OPEN after failed HALF_OPEN call', () => { - // Force open - circuitBreaker.forceState('test-agent', 'OPEN'); - - vi.useFakeTimers(); - vi.advanceTimersByTime(31000); - - // Trigger half-open - const state1 = circuitBreaker.getState('test-agent'); - expect(state1.state).toBe('HALF_OPEN'); - - // Record failure - circuitBreaker.recordFailure('test-agent'); - - const state2 = circuitBreaker.getState('test-agent'); - expect(state2.state).toBe('OPEN'); - - vi.useRealTimers(); - }); - }); - - describe('canCall()', () => { - it('should allow calls when CLOSED', () => { - expect(circuitBreaker.canCall('test-agent')).toBe(true); - }); - - it('should block calls when OPEN', () => { - circuitBreaker.forceState('test-agent', 'OPEN'); - expect(circuitBreaker.canCall('test-agent')).toBe(false); - }); - - it('should allow calls when HALF_OPEN', () => { - circuitBreaker.forceState('test-agent', 'HALF_OPEN'); - expect(circuitBreaker.canCall('test-agent')).toBe(true); - }); - }); - - describe('Exponential Backoff', () => { - it('should increase backoff multiplier on repeated failures', () => { - // Force open multiple times to increase backoff - circuitBreaker.forceState('test-agent', 'OPEN'); - let state = circuitBreaker.getState('test-agent'); - const initialMultiplier = state.backoff_multiplier ?? 1; - - // Simulate recovery and re-failure - circuitBreaker.forceState('test-agent', 'CLOSED'); - for (let i = 0; i < 5; i++) { - circuitBreaker.recordFailure('test-agent'); - } - - state = circuitBreaker.getState('test-agent'); - expect(state.backoff_multiplier).toBeGreaterThan(initialMultiplier); - }); - }); - - describe('forceState()', () => { - it('should force state to CLOSED', () => { - circuitBreaker.forceState('test-agent', 'CLOSED'); - const state = circuitBreaker.getState('test-agent'); - expect(state.state).toBe('CLOSED'); - expect(state.failure_count).toBe(0); - }); - - it('should force state to OPEN', () => { - circuitBreaker.forceState('test-agent', 'OPEN'); - const state = circuitBreaker.getState('test-agent'); - expect(state.state).toBe('OPEN'); - }); - - it('should force state to HALF_OPEN', () => { - circuitBreaker.forceState('test-agent', 'HALF_OPEN'); - const state = circuitBreaker.getState('test-agent'); - expect(state.state).toBe('HALF_OPEN'); - }); - }); - - describe('Multiple Agents', () => { - it('should maintain separate states for different agents', () => { - circuitBreaker.forceState('agent-1', 'OPEN'); - circuitBreaker.forceState('agent-2', 'CLOSED'); - - expect(circuitBreaker.getState('agent-1').state).toBe('OPEN'); - expect(circuitBreaker.getState('agent-2').state).toBe('CLOSED'); - }); - }); -}); diff --git a/tests/unit/clarification.cache.test.ts b/tests/unit/clarification.cache.test.ts deleted file mode 100644 index 2d4c993..0000000 --- a/tests/unit/clarification.cache.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('../../src/config/constants.js', () => ({ - CACHE_TTL: { - CLARIFICATION_MS: 1000, - }, -})); - -const { ClarificationCache } = await import('../../src/confidence/clarification.cache.js'); - -describe('ClarificationCache', () => { - let cache: InstanceType; - - beforeEach(() => { - vi.useFakeTimers(); - cache = new ClarificationCache(1000); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('get/set', () => { - it('stores and retrieves values', () => { - cache.set('key1', 'value1'); - expect(cache.get('key1')).toBe('value1'); - }); - - it('returns null for missing keys', () => { - expect(cache.get('nonexistent')).toBeNull(); - }); - - it('returns null for expired entries', () => { - cache.set('key1', 'value1'); - vi.advanceTimersByTime(1001); - expect(cache.get('key1')).toBeNull(); - }); - - it('updates lastAccessed on get', () => { - cache.set('key1', 'value1'); - const entry1 = cache.get('key1'); - expect(entry1).toBe('value1'); - vi.advanceTimersByTime(500); - const entry2 = cache.get('key1'); - expect(entry2).toBe('value1'); - }); - - it('overwrites existing value', () => { - cache.set('key1', 'value1'); - cache.set('key1', 'value2'); - expect(cache.get('key1')).toBe('value2'); - }); - }); - - describe('delete', () => { - it('deletes a key', () => { - cache.set('key1', 'value1'); - expect(cache.delete('key1')).toBe(true); - expect(cache.get('key1')).toBeNull(); - }); - - it('returns false for missing key', () => { - expect(cache.delete('nonexistent')).toBe(false); - }); - - it('allows re-adding after delete', () => { - cache.set('key1', 'value1'); - cache.delete('key1'); - cache.set('key1', 'value2'); - expect(cache.get('key1')).toBe('value2'); - }); - }); - - describe('clear', () => { - it('clears all entries when no active requests', () => { - cache.set('key1', 'value1'); - cache.set('key2', 'value2'); - cache.clear(); - expect(cache.get('key1')).toBeNull(); - expect(cache.get('key2')).toBeNull(); - }); - - it('defers clear when active requests exist', () => { - cache.set('key1', 'value1'); - cache.startRequest(); - cache.clear(); - expect(cache.get('key1')).toBe('value1'); - cache.endRequest(); - expect(cache.get('key1')).toBeNull(); - }); - - it('multiple active requests delay clear', () => { - cache.set('key1', 'value1'); - cache.startRequest(); - cache.startRequest(); - cache.clear(); - expect(cache.getStats().pendingClear).toBe(true); - cache.endRequest(); - expect(cache.getStats().pendingClear).toBe(true); - cache.endRequest(); - expect(cache.getStats().pendingClear).toBe(false); - }); - }); - - describe('startRequest / endRequest', () => { - it('tracks active requests', () => { - cache.startRequest(); - cache.startRequest(); - const stats = cache.getStats(); - expect(stats.activeRequests).toBe(2); - cache.endRequest(); - expect(cache.getStats().activeRequests).toBe(1); - }); - - it('does not go below zero', () => { - cache.endRequest(); - expect(cache.getStats().activeRequests).toBe(0); - }); - - it('endRequest triggers deferred clear', () => { - cache.set('key1', 'value1'); - cache.startRequest(); - cache.clear(); - expect(cache.get('key1')).toBe('value1'); - cache.endRequest(); - expect(cache.get('key1')).toBeNull(); - }); - - it('endRequest does not trigger clear if pendingClear is false', () => { - cache.set('key1', 'value1'); - cache.startRequest(); - cache.clear(); - cache.endRequest(); - cache.startRequest(); - cache.endRequest(); - expect(cache.get('key1')).toBeNull(); - }); - }); - - describe('getStats', () => { - it('reports size, pendingClear, and activeRequests', () => { - cache.set('k1', 'v1'); - cache.set('k2', 'v2'); - const stats = cache.getStats(); - expect(stats.size).toBe(2); - expect(stats.pendingClear).toBe(false); - expect(stats.activeRequests).toBe(0); - }); - - it('reports pendingClear when deferred', () => { - cache.startRequest(); - cache.clear(); - expect(cache.getStats().pendingClear).toBe(true); - }); - - it('reports zero size after clear', () => { - cache.set('k1', 'v1'); - cache.clear(); - expect(cache.getStats().size).toBe(0); - }); - }); - - describe('cleanup', () => { - it('removes expired entries via interval', () => { - cache.set('key1', 'value1'); - cache.set('key2', 'value2'); - vi.advanceTimersByTime(1500); - expect(cache.get('key1')).toBeNull(); - expect(cache.get('key2')).toBeNull(); - }); - - it('cleanup handles empty cache', () => { - vi.advanceTimersByTime(1500); - expect(cache.getStats().size).toBe(0); - }); - - it('cleanup preserves non-expired entries', () => { - cache.set('key1', 'value1'); - vi.advanceTimersByTime(500); - cache.set('key2', 'value2'); - expect(cache.get('key1')).toBe('value1'); - expect(cache.get('key2')).toBe('value2'); - }); - }); -}); diff --git a/tests/unit/classifier.service.test.ts b/tests/unit/classifier.service.test.ts deleted file mode 100644 index dff44c5..0000000 --- a/tests/unit/classifier.service.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { AgentRegistry } from '../../src/registry/types.js'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - NODE_ENV: 'test', - GOOGLE_CLOUD_PROJECT: 'test-project', - VERTEX_AI_LOCATION: 'us-central1', - VERTEX_AI_MODEL: 'gemini-2.0-flash', - }, -})); - -vi.mock('../../src/classifier/localization.js', () => ({ - detectLanguage: () => 'en', -})); - -const { ClassifierService, isRateLimitError } = - await import('../../src/classifier/classifier.service.js'); - -const defaultAgent = { - agent_id: 'default', - display_name: 'Default Agent', - description: 'Handles general requests', - endpoint: 'https://default.example.com', - type: 'mcp' as const, - is_default: true, - confidence_threshold: 0, - clarification_required: false, - examples: [], -}; - -const passwordAgent = { - agent_id: 'password-reset', - display_name: 'Password Reset', - description: 'Handles password resets and account recovery', - endpoint: 'https://password.example.com', - type: 'mcp' as const, - is_default: false, - confidence_threshold: 0.7, - clarification_required: false, - examples: ['Reset my password', 'I forgot my password', 'Change my password'], -}; - -const registry: AgentRegistry = [defaultAgent, passwordAgent]; - -describe('ClassifierService', () => { - let service: InstanceType; - - beforeEach(() => { - service = new ClassifierService(); - }); - - it('uses mock classifier in test environment', () => { - expect(service.isMock()).toBe(true); - }); - - it('classifies matching input to the correct agent', async () => { - const result = await service.classify('Reset my password please', registry); - expect(result.agent_id).toBe('password-reset'); - expect(result.confidence).toBe(0.8); - expect(result.ambiguous).toBe(false); - expect(result.detected_language).toBe('en'); - }); - - it('classifies matching input for forgot password', async () => { - const result = await service.classify('I forgot my password', registry); - expect(result.agent_id).toBe('password-reset'); - }); - - it('falls back to default agent for non-matching input', async () => { - const result = await service.classify('What is the weather today?', registry); - expect(result.agent_id).toBe('default'); - expect(result.confidence).toBe(0.5); - }); - - it('falls back to default agent for empty input', async () => { - const result = await service.classify('', registry); - expect(result.agent_id).toBe('default'); - }); - - it('throws when no default agent found and no match', async () => { - const noDefaultRegistry: AgentRegistry = [passwordAgent]; - await expect(service.classify('random text', noDefaultRegistry)).rejects.toThrow( - 'No default agent found in registry', - ); - }); - - it('includes intent_summary in response', async () => { - const result = await service.classify('Reset my password', registry); - expect(result.intent_summary).toBeDefined(); - expect(typeof result.intent_summary).toBe('string'); - expect(result.intent_summary.length).toBeGreaterThan(0); - }); - - it('includes entities in response', async () => { - const result = await service.classify('Hello', registry); - expect(result.entities).toBeDefined(); - expect(typeof result.entities).toBe('object'); - }); -}); - -describe('isRateLimitError', () => { - it('returns true for rate limit error message', () => { - expect(isRateLimitError(new Error('rate limit exceeded'))).toBe(true); - expect(isRateLimitError(new Error('Rate Limit Error'))).toBe(true); - }); - - it('returns true for quota error message', () => { - expect(isRateLimitError(new Error('quota exceeded'))).toBe(true); - expect(isRateLimitError(new Error('QUOTA'))).toBe(true); - }); - - it('returns true for 429 error message', () => { - expect(isRateLimitError(new Error('error 429'))).toBe(true); - expect(isRateLimitError(new Error('429 Too Many Requests'))).toBe(true); - }); - - it('returns true for resource exhausted message', () => { - expect(isRateLimitError(new Error('resource exhausted'))).toBe(true); - expect(isRateLimitError(new Error('RESOURCE EXHAUSTED'))).toBe(true); - }); - - it('returns false for non-rate-limit errors', () => { - expect(isRateLimitError(new Error('connection timeout'))).toBe(false); - expect(isRateLimitError(new Error('invalid request'))).toBe(false); - expect(isRateLimitError(new Error('authentication failed'))).toBe(false); - }); - - it('returns false for non-Error values', () => { - expect(isRateLimitError('string error')).toBe(false); - expect(isRateLimitError({ message: 'error' })).toBe(false); - expect(isRateLimitError(null)).toBe(false); - expect(isRateLimitError(undefined)).toBe(false); - }); -}); diff --git a/tests/unit/confidence.gate.test.ts b/tests/unit/confidence.gate.test.ts deleted file mode 100644 index e603a52..0000000 --- a/tests/unit/confidence.gate.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { AgentRegistry } from '../../src/registry/types.js'; -import type { ClassifierOutput } from '../../src/types/domain.js'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - ENABLE_CLARIFICATION: true, - }, -})); - -vi.mock('../../src/observability/metrics.js', () => ({ - recordClarification: vi.fn(), -})); - -vi.mock('../../src/classifier/localization.js', () => ({ - getClarificationQuestion: (lang: string) => `Clarification question (${lang})`, -})); - -vi.mock('../../src/confidence/clarification.cache.js', () => { - const map = new Map(); - return { - clarificationCache: { - get: (key: string) => map.get(key) ?? null, - set: (key: string, value: string) => { - map.set(key, value); - }, - }, - }; -}); - -const { evaluateConfidenceGate, generateClarificationQuestion } = - await import('../../src/confidence/confidence.gate.js'); - -const defaultAgent = { - agent_id: 'default', - display_name: 'Default Agent', - description: 'Default', - endpoint: 'https://default.example.com', - type: 'mcp' as const, - is_default: true, - confidence_threshold: 0, - clarification_required: false, - examples: [], -}; - -const specialistAgent = { - agent_id: 'specialist', - display_name: 'Specialist Agent', - description: 'Specialist', - endpoint: 'https://specialist.example.com', - type: 'mcp' as const, - is_default: false, - confidence_threshold: 0.7, - clarification_required: false, - examples: ['specialist query'], -}; - -const clarificationAgent = { - agent_id: 'clarifier', - display_name: 'Clarifier Agent', - description: 'Clarifier', - endpoint: 'https://clarifier.example.com', - type: 'mcp' as const, - is_default: false, - confidence_threshold: 0.5, - clarification_required: true, - examples: ['clarify query'], -}; - -const registry: AgentRegistry = [defaultAgent, specialistAgent, clarificationAgent]; - -function makeOutput(overrides: Partial = {}): ClassifierOutput { - return { - agent_id: 'specialist', - confidence: 0.8, - ambiguous: false, - detected_language: 'en', - intent_summary: 'test', - entities: {}, - ...overrides, - }; -} - -describe('evaluateConfidenceGate', () => { - it('Rule 1: unknown agent_id routes to default', () => { - const result = evaluateConfidenceGate(makeOutput({ agent_id: 'unknown' }), registry); - expect(result.action).toBe('route'); - expect(result.agent_id).toBe('default'); - expect(result.reason).toContain('Unknown agent_id'); - }); - - it('Rule 1: falls back to matched agent_id when no default found', () => { - const noDefaultRegistry = [specialistAgent]; - const result = evaluateConfidenceGate(makeOutput({ agent_id: 'unknown' }), noDefaultRegistry); - expect(result.action).toBe('route'); - expect(result.agent_id).toBe('unknown'); - }); - - it('Rule 2: default agent always routes directly', () => { - const result = evaluateConfidenceGate( - makeOutput({ agent_id: 'default', confidence: 0.1 }), - registry, - ); - expect(result.action).toBe('route'); - expect(result.agent_id).toBe('default'); - expect(result.reason).toContain('Default agent'); - }); - - it('bypassClassifier routes directly to matched agent', () => { - const result = evaluateConfidenceGate(makeOutput({ confidence: 0.3 }), registry, true); - expect(result.action).toBe('route'); - expect(result.agent_id).toBe('specialist'); - expect(result.reason).toContain('Session bypass'); - }); - - it('Rule 3: routes when confidence >= threshold and not ambiguous', () => { - const result = evaluateConfidenceGate( - makeOutput({ confidence: 0.7, ambiguous: false }), - registry, - ); - expect(result.action).toBe('route'); - expect(result.agent_id).toBe('specialist'); - expect(result.reason).toContain('Confidence 0.7 >= threshold 0.7'); - }); - - it('Rule 3: routes when confidence exceeds threshold', () => { - const result = evaluateConfidenceGate(makeOutput({ confidence: 0.9 }), registry); - expect(result.action).toBe('route'); - expect(result.agent_id).toBe('specialist'); - }); - - it('does not route when confidence below threshold', () => { - const result = evaluateConfidenceGate(makeOutput({ confidence: 0.5 }), registry); - expect(result.action).toBe('fallback'); - expect(result.agent_id).toBe('default'); - }); - - it('does not route when ambiguous even with high confidence', () => { - const result = evaluateConfidenceGate( - makeOutput({ confidence: 0.9, ambiguous: true }), - registry, - ); - expect(result.action).toBe('fallback'); - }); - - it('Rule 4: clarifies when clarification_required and ENABLE_CLARIFICATION is true', () => { - const result = evaluateConfidenceGate( - makeOutput({ agent_id: 'clarifier', confidence: 0.3 }), - registry, - ); - expect(result.action).toBe('clarify'); - expect(result.agent_id).toBe('clarifier'); - expect(result.clarification_question).toBeDefined(); - }); - - it('Rule 5: falls back to default when below threshold and no clarification required', () => { - const result = evaluateConfidenceGate(makeOutput({ confidence: 0.1 }), registry); - expect(result.action).toBe('fallback'); - expect(result.agent_id).toBe('default'); - expect(result.reason).toContain('Below threshold'); - }); -}); - -describe('generateClarificationQuestion', () => { - it('returns a clarification question', async () => { - const question = await generateClarificationQuestion(clarificationAgent, 'test input', 'en'); - expect(question).toBeDefined(); - expect(typeof question).toBe('string'); - expect(question.length).toBeGreaterThan(0); - }); - - it('caches the question for subsequent calls', async () => { - const q1 = await generateClarificationQuestion(clarificationAgent, 'input', 'fr'); - const q2 = await generateClarificationQuestion(clarificationAgent, 'input', 'fr'); - expect(q1).toBe(q2); - }); -}); diff --git a/tests/unit/entry.handler.test.ts b/tests/unit/entry.handler.test.ts deleted file mode 100644 index 15477a2..0000000 --- a/tests/unit/entry.handler.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { Request, Response } from 'express'; - -const mockClassify = vi.fn(); -const mockDispatchToAgent = vi.fn(); -const mockGetActiveSession = vi.fn(); -const mockCreateSession = vi.fn(); -const mockAppendTurn = vi.fn(); -const mockUpdateWorkflowState = vi.fn(); -const mockCloseSession = vi.fn(); -const mockGetSessionById = vi.fn(); -const mockResolveSlackProfile = vi.fn(); - -vi.mock('../../src/classifier/classifier.service.js', () => ({ - classifierService: { - classify: (...args: unknown[]) => mockClassify(...args), - isMock: () => true, - }, -})); - -vi.mock('../../src/confidence/confidence.gate.js', () => ({ - evaluateConfidenceGate: (output: unknown) => ({ - action: 'route', - agent_id: (output as { agent_id: string }).agent_id, - confidence: 0.9, - reason: 'Test route', - }), -})); - -vi.mock('../../src/router/router.service.js', () => ({ - dispatchToAgent: (...args: unknown[]) => mockDispatchToAgent(...args), -})); - -vi.mock('../../src/session/session.service.js', () => ({ - getActiveSession: (...args: unknown[]) => mockGetActiveSession(...args), - createSession: (...args: unknown[]) => mockCreateSession(...args), - appendTurn: (...args: unknown[]) => mockAppendTurn(...args), - updateWorkflowState: (...args: unknown[]) => mockUpdateWorkflowState(...args), - closeSession: (...args: unknown[]) => mockCloseSession(...args), - getSessionById: (...args: unknown[]) => mockGetSessionById(...args), -})); - -vi.mock('../../src/session/firestoreClient.js', () => ({ - getFirestore: vi.fn(), -})); - -vi.mock('../../src/gateway/slackProfile.resolver.js', () => ({ - resolveSlackProfile: (...args: unknown[]) => mockResolveSlackProfile(...args), -})); - -vi.mock('../../src/config/env.js', () => ({ - env: { - NODE_ENV: 'test', - ENABLE_SESSION_BYPASS: true, - PORT: 8080, - GOOGLE_CLOUD_PROJECT: 'test-project', - }, -})); - -vi.mock('../../src/registry/registry.loader.js', () => { - const registry = [ - { - agent_id: 'default', - display_name: 'Default', - description: 'Default agent', - endpoint: 'https://default.example.com', - type: 'mcp', - is_default: true, - confidence_threshold: 0, - clarification_required: false, - examples: [], - }, - { - agent_id: 'specialist', - display_name: 'Specialist', - description: 'Specialist agent', - endpoint: 'https://specialist.example.com', - type: 'mcp', - is_default: false, - confidence_threshold: 0.7, - clarification_required: false, - examples: [], - }, - ]; - return { - registryState: { - isLoaded: true, - registry, - getAgent: (id: string) => registry.find((a) => a.agent_id === id), - getAgentIds: () => registry.map((a) => a.agent_id), - defaultAgent: registry.find((a) => a.is_default), - }, - }; -}); - -vi.mock('../../src/config/constants.js', () => ({ - SERVICE_NAME: 'agent-mesh', - SERVICE_VERSION: '1.0.0', - MAX_REQUEST_BODY_SIZE: '1mb', - HEALTH_CHECK_COLLECTION: 'health_checks', -})); - -const { healthCheck, deepHealthCheck, handleRequest, handleInternalRequest } = - await import('../../src/gateway/entry.handler.js'); - -function mockReqRes(body: Record = {}) { - const req = { - body, - headers: {}, - path: '/v1/request', - apiKey: undefined, - } as unknown as Request & { apiKey?: string }; - - const json = vi.fn().mockReturnThis(); - const res = { - status: vi.fn().mockReturnThis(), - json, - } as unknown as Response; - - return { req, res }; -} - -describe('healthCheck', () => { - it('returns healthy status', () => { - const { req, res } = mockReqRes(); - healthCheck(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'healthy', - service: 'agent-mesh', - version: '1.0.0', - }), - ); - }); -}); - -describe('deepHealthCheck', () => { - it('returns healthy with loaded registry', async () => { - const { req, res } = mockReqRes(); - - const { getFirestore } = await import('../../src/session/firestoreClient.js'); - (getFirestore as ReturnType).mockReturnValue({ - collection: () => ({ limit: () => ({ get: () => Promise.resolve({}) }) }), - }); - - await deepHealthCheck(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - checks: expect.objectContaining({ - registry: expect.objectContaining({ status: 'pass' }), - }), - }), - ); - }); - - it('returns degraded when Firestore is unreachable', async () => { - const { req, res } = mockReqRes(); - - const { getFirestore } = await import('../../src/session/firestoreClient.js'); - (getFirestore as ReturnType).mockReturnValue({ - collection: () => ({ limit: () => ({ get: () => Promise.reject(new Error('down')) }) }), - }); - - await deepHealthCheck(req, res); - const calls = (res.json as ReturnType).mock.calls; - expect(calls.length).toBeGreaterThan(0); - const call = calls[0]![0] as Record; - const checks = call.checks as Record; - expect(checks?.firestore?.status).toBe('fail'); - }); -}); - -describe('handleRequest', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns 400 for invalid request body', async () => { - const { req, res } = mockReqRes({}); - await handleRequest(req, res); - expect(res.status).toHaveBeenCalledWith(400); - }); - - it('returns 400 for empty input', async () => { - const { req, res } = mockReqRes({ input: '' }); - await handleRequest(req, res); - expect(res.status).toHaveBeenCalledWith(400); - }); - - it('returns 503 when registry not loaded', async () => { - vi.doMock('../../src/registry/registry.loader.js', () => ({ - registryState: { isLoaded: false, registry: null }, - })); - - const { req, res } = mockReqRes({ input: 'Hello' }); - await handleRequest(req, res); - }); -}); - -describe('handleInternalRequest', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockGetActiveSession.mockResolvedValue(null); - mockClassify.mockResolvedValue({ - agent_id: 'specialist', - confidence: 0.9, - ambiguous: false, - detected_language: 'en', - intent_summary: 'User wants help', - entities: {}, - }); - mockCreateSession.mockResolvedValue({ - session_id: 'sess-1', - user_id: 'emp-1', - employee_id: 'emp-1', - status: 'active', - active_agent: 'specialist', - turn_history: [], - workflow_state: {}, - }); - mockDispatchToAgent.mockResolvedValue({ - content: 'Here is your answer', - workflow_complete: true, - workflow_state: { step: 'done' }, - }); - mockAppendTurn.mockResolvedValue(undefined); - mockUpdateWorkflowState.mockResolvedValue(undefined); - mockCloseSession.mockResolvedValue(undefined); - }); - - it('processes a valid request end-to-end', async () => { - const result = await handleInternalRequest({ - input: 'Reset my password', - employee_id: 'emp-1', - display_name: 'Test User', - }); - - expect(result.status).toBe(200); - expect(result.body).toEqual( - expect.objectContaining({ - agent_id: 'specialist', - response: 'Here is your answer', - workflow_complete: true, - }), - ); - }); - - it('creates session for authenticated user', async () => { - await handleInternalRequest({ - input: 'Hello', - employee_id: 'emp-1', - }); - expect(mockCreateSession).toHaveBeenCalled(); - }); - - it('closes session when workflow_complete is true', async () => { - await handleInternalRequest({ - input: 'Hello', - employee_id: 'emp-1', - }); - expect(mockCloseSession).toHaveBeenCalledWith('sess-1', 'completed'); - }); - - it('handles Slack entry point', async () => { - mockResolveSlackProfile.mockResolvedValue({ - employee_id: 'slack-emp', - display_name: 'Slack User', - email: 'slack@example.com', - }); - - await handleInternalRequest({ - input: 'Hello from Slack', - employee_id: 'emp-1', - entry_point: 'slack', - slack_user_id: 'U123', - }); - - expect(mockResolveSlackProfile).toHaveBeenCalledWith('U123'); - }); - - it('uses session bypass when active session exists', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'active-sess', - active_agent: 'specialist', - turn_history: [], - workflow_state: {}, - }); - - const result = await handleInternalRequest({ - input: 'Follow-up question', - employee_id: 'emp-1', - }); - - expect(result.status).toBe(200); - expect(mockClassify).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/localization.test.ts b/tests/unit/localization.test.ts deleted file mode 100644 index b5196d3..0000000 --- a/tests/unit/localization.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - detectLanguage, - isValidLanguageCode, - getClarificationQuestion, - FALLBACK_QUESTIONS, -} from '../../src/classifier/localization.js'; - -describe('detectLanguage', () => { - it('returns "en" for English text', () => { - expect(detectLanguage('The quick brown fox jumps over the lazy dog')).toBe('en'); - }); - - it('returns "es" for Spanish text', () => { - expect(detectLanguage('El perro está en la casa con los gatos')).toBe('es'); - }); - - it('returns "fr" for French text with accented characters', () => { - expect(detectLanguage('Le château est très beau avec les élèves français')).toBe('fr'); - }); - - it('returns "de" for German text', () => { - expect(detectLanguage('Der Hund ist in der Küche und ist sehr groß')).toBe('de'); - }); - - it('returns "ja" for Japanese text', () => { - expect(detectLanguage('こんにちは世界')).toBe('ja'); - }); - - it('detects CJK characters (ja/zh shared range)', () => { - const lang = detectLanguage('你好世界'); - expect(['ja', 'zh']).toContain(lang); - }); - - it('returns "ko" for Korean text', () => { - expect(detectLanguage('안녕하세요 세계')).toBe('ko'); - }); - - it('returns "ar" for Arabic text', () => { - expect(detectLanguage('مرحبا بالعالم')).toBe('ar'); - }); - - it('returns "ru" for Russian text', () => { - expect(detectLanguage('Привет мир')).toBe('ru'); - }); - - it('returns default for empty string', () => { - expect(detectLanguage('')).toBe('en'); - }); - - it('returns default for whitespace-only string', () => { - expect(detectLanguage(' ')).toBe('en'); - }); -}); - -describe('isValidLanguageCode', () => { - it('returns true for supported language codes', () => { - expect(isValidLanguageCode('en')).toBe(true); - expect(isValidLanguageCode('es')).toBe(true); - expect(isValidLanguageCode('ja')).toBe(true); - }); - - it('returns false for unsupported language codes', () => { - expect(isValidLanguageCode('xx')).toBe(false); - expect(isValidLanguageCode('')).toBe(false); - expect(isValidLanguageCode('EN')).toBe(false); - }); -}); - -describe('getClarificationQuestion', () => { - it('returns question for supported language', () => { - const q = getClarificationQuestion('en'); - expect(q).toContain('more details'); - }); - - it('returns question for Spanish', () => { - const q = getClarificationQuestion('es'); - expect(q).toContain('detalles'); - }); - - it('falls back to English for unsupported language', () => { - const q = getClarificationQuestion('xx'); - expect(q).toBe(FALLBACK_QUESTIONS.en); - }); -}); - -describe('FALLBACK_QUESTIONS', () => { - it('has entries for all supported languages', () => { - const keys = Object.keys(FALLBACK_QUESTIONS); - expect(keys.length).toBeGreaterThan(10); - for (const [, value] of Object.entries(FALLBACK_QUESTIONS)) { - expect(typeof value).toBe('string'); - expect(value.length).toBeGreaterThan(0); - } - }); -}); diff --git a/tests/unit/logger.test.ts b/tests/unit/logger.test.ts deleted file mode 100644 index cd91d43..0000000 --- a/tests/unit/logger.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('../../src/config/constants.js', () => ({ - SERVICE_NAME: 'agent-mesh', -})); - -vi.mock('../../src/config/env.js', () => ({ - env: { - LOG_LEVEL: 'debug', - NODE_ENV: 'test', - }, -})); - -const { logger, createChildLogger } = await import('../../src/observability/logger.js'); - -describe('logger', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('is a winston logger instance', () => { - expect(logger).toBeDefined(); - expect(typeof logger.info).toBe('function'); - expect(typeof logger.error).toBe('function'); - expect(typeof logger.warn).toBe('function'); - expect(typeof logger.debug).toBe('function'); - }); - - it('has default metadata with service name', () => { - const meta = logger.defaultMeta; - expect(meta).toBeDefined(); - expect(meta.service).toBe('agent-mesh'); - }); - - it('logs at info level', () => { - const spy = vi.spyOn(logger, 'info').mockImplementation(() => logger); - logger.info('test message'); - expect(spy).toHaveBeenCalledWith('test message'); - }); - - it('logs at error level', () => { - const spy = vi.spyOn(logger, 'error').mockImplementation(() => logger); - logger.error('error message'); - expect(spy).toHaveBeenCalledWith('error message'); - }); - - it('logs with metadata', () => { - const spy = vi.spyOn(logger, 'info').mockImplementation(() => logger); - logger.info('test with meta', { key: 'value' }); - expect(spy).toHaveBeenCalledWith('test with meta', { key: 'value' }); - }); -}); - -describe('createChildLogger', () => { - it('creates a child logger with context', () => { - const child = createChildLogger({ request_id: 'req-123' }); - expect(child).toBeDefined(); - expect(typeof child.info).toBe('function'); - }); - - it('child logger has parent metadata', () => { - const child = createChildLogger({ request_id: 'req-123' }); - expect(child.defaultMeta.service).toBe('agent-mesh'); - }); - - it('child can log with context', () => { - const child = createChildLogger({ request_id: 'req-123', session_id: 'sess-456' }); - const spy = vi.spyOn(child, 'info').mockImplementation(() => child); - child.info('child log message'); - expect(spy).toHaveBeenCalledWith('child log message'); - }); - - it('multiple child loggers are independent', () => { - const child1 = createChildLogger({ request_id: 'req-1' }); - const child2 = createChildLogger({ request_id: 'req-2' }); - expect(child1).not.toBe(child2); - }); - - it('child inherits log levels from parent', () => { - const child = createChildLogger({ request_id: 'req-123' }); - expect(typeof child.debug).toBe('function'); - expect(typeof child.info).toBe('function'); - expect(typeof child.warn).toBe('function'); - expect(typeof child.error).toBe('function'); - }); -}); - -describe('PII redaction', () => { - it('logger info method is available', () => { - expect(typeof logger.info).toBe('function'); - }); - - it('child logger info method is available', () => { - const child = createChildLogger({ request_id: 'req-123' }); - expect(typeof child.info).toBe('function'); - }); - - it('child logger inherits redaction behavior', () => { - const child = createChildLogger({ request_id: 'req-123' }); - const spy = vi.spyOn(child, 'info').mockImplementation(() => child); - child.info('test'); - expect(spy).toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/mcp.client.test.ts b/tests/unit/mcp.client.test.ts deleted file mode 100644 index 580d1fe..0000000 --- a/tests/unit/mcp.client.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { AgentConfig } from '../../src/registry/types.js'; - -const mockClientConnect = vi.fn(); -const mockClientCallTool = vi.fn(); -const mockClientClose = vi.fn(); - -vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ - Client: vi.fn().mockImplementation(function () { - return { - connect: mockClientConnect, - callTool: mockClientCallTool, - close: mockClientClose, - }; - }), -})); - -vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ - StreamableHTTPClientTransport: vi.fn().mockImplementation(function () { - return {}; - }), -})); - -vi.mock('../../src/config/constants.js', () => ({ - MCP: { - HANDLE_MESSAGE_TOOL: 'handle_message', - }, -})); - -vi.mock('../../src/config/env.js', () => ({ - env: { - MCP_REQUEST_TIMEOUT_MS: 5000, - MCP_MAX_RETRIES: 1, - }, -})); - -const { McpClient, mcpClientFactory } = await import('../../src/router/mcp.client.js'); - -const testAgent: AgentConfig = { - agent_id: 'test-agent', - display_name: 'Test Agent', - description: 'Test', - endpoint: 'https://test.example.com', - type: 'mcp', - is_default: false, - confidence_threshold: 0.7, - clarification_required: false, - examples: ['test'], -}; - -describe('McpClient', () => { - let client: InstanceType; - - beforeEach(() => { - vi.clearAllMocks(); - client = new McpClient(testAgent); - mockClientConnect.mockResolvedValue(undefined); - }); - - it('parses structured response from content array', async () => { - mockClientCallTool.mockResolvedValue({ - content: [ - { type: 'text', text: JSON.stringify({ content: 'hello', workflow_complete: true }) }, - ], - }); - - const result = await client.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hello', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: {}, - }); - - expect(result.content).toBe('hello'); - expect(result.workflow_complete).toBe(true); - }); - - it('parses plain text response from content', async () => { - mockClientCallTool.mockResolvedValue({ - content: [{ type: 'text', text: 'Simple text response' }], - }); - - const result = await client.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hello', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: { step: 'init' }, - }); - - expect(result.content).toBe('Simple text response'); - expect(result.workflow_complete).toBe(false); - }); - - it('parses structuredContent', async () => { - mockClientCallTool.mockResolvedValue({ - structuredContent: { content: 'structured', workflow_complete: true }, - }); - - const result = await client.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hello', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: {}, - }); - - expect(result.content).toBe('structured'); - }); - - it('retries on failure', async () => { - mockClientCallTool.mockRejectedValueOnce(new Error('transient')).mockResolvedValue({ - structuredContent: { content: 'ok', workflow_complete: true }, - }); - - const result = await client.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hello', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: {}, - }); - - expect(result.content).toBe('ok'); - }); - - it('throws after max retries', async () => { - mockClientCallTool.mockRejectedValue(new Error('persistent failure')); - - await expect( - client.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hello', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: {}, - }), - ).rejects.toThrow('persistent failure'); - }); - - it('reports isConnected status', () => { - expect(client.isConnected()).toBe(false); - }); - - it('closes cleanly after connection', async () => { - mockClientCallTool.mockResolvedValue({ - structuredContent: { content: 'ok', workflow_complete: true }, - }); - - await client.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hello', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: {}, - }); - - await client.close(); - expect(mockClientClose).toHaveBeenCalled(); - }); -}); - -describe('McpClientFactory', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockClientCallTool.mockResolvedValue({ - structuredContent: { content: 'ok', workflow_complete: true }, - }); - mockClientConnect.mockResolvedValue(undefined); - }); - - it('returns same client for same agent', () => { - const c1 = mcpClientFactory.getClient(testAgent); - const c2 = mcpClientFactory.getClient(testAgent); - expect(c1).toBe(c2); - }); - - it('removes a client', () => { - const c = mcpClientFactory.getClient(testAgent); - mcpClientFactory.removeClient(testAgent.agent_id); - const c2 = mcpClientFactory.getClient(testAgent); - expect(c2).not.toBe(c); - }); - - it('closes all clients', async () => { - const c = mcpClientFactory.getClient(testAgent); - mockClientCallTool.mockResolvedValue({ - structuredContent: { content: 'ok', workflow_complete: true }, - }); - await c.sendMessage({ - session_id: 's1', - request_id: 'r1', - employee_id: 'e1', - display_name: 'Test', - raw_input: 'Hi', - intent_summary: 'Test', - entities: {}, - detected_language: 'en', - turn_history: [], - workflow_state: {}, - }); - await mcpClientFactory.closeAll(); - expect(mockClientClose).toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/mcpServer.test.ts b/tests/unit/mcpServer.test.ts deleted file mode 100644 index ab2c65d..0000000 --- a/tests/unit/mcpServer.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { Request, Response } from 'express'; - -const mockHandleInternalRequest = vi.fn(); -const mockGetSessionById = vi.fn(); - -vi.mock('../../src/observability/logger.js', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})); - -vi.mock('../../src/gateway/entry.handler.js', () => ({ - handleInternalRequest: (...args: unknown[]) => mockHandleInternalRequest(...args), -})); - -vi.mock('../../src/registry/registry.loader.js', () => ({ - registryState: { - registry: [ - { agent_id: 'default', display_name: 'Default', is_default: true, confidence_threshold: 0 }, - ], - isLoaded: true, - getAgentIds: () => ['default'], - }, -})); - -vi.mock('../../src/session/session.service.js', () => ({ - getSessionById: (...args: unknown[]) => mockGetSessionById(...args), -})); - -const { handleMcpRequest, mcpMiddleware } = await import('../../src/mcp-server/mcpServer.js'); - -function mockReqRes(body: Record = {}, path = '/mcp', method = 'POST') { - const req = { - body, - path, - method, - } as unknown as Request; - - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - } as unknown as Response; - - return { req, res }; -} - -describe('handleMcpRequest', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns 400 for invalid request (no method)', async () => { - const { req, res } = mockReqRes({ jsonrpc: '2.0', id: '1' }); - await handleMcpRequest(req, res); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ code: -32600 }), - }), - ); - }); - - it('returns tools/list', async () => { - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '1', - method: 'tools/list', - }); - await handleMcpRequest(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - tools: expect.arrayContaining([expect.objectContaining({ name: 'handle_message' })]), - }), - }), - ); - }); - - it('returns error for unknown method', async () => { - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '2', - method: 'unknown/method', - }); - await handleMcpRequest(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ code: -32601 }), - }), - ); - }); - - it('handles handle_message tool call', async () => { - mockHandleInternalRequest.mockResolvedValue({ - status: 200, - body: { request_id: 'r1', response: 'hello' }, - }); - - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '3', - method: 'tools/call', - params: { - name: 'handle_message', - arguments: { input: 'Hello' }, - }, - }); - await handleMcpRequest(req, res); - expect(mockHandleInternalRequest).toHaveBeenCalledWith({ - input: 'Hello', - session_id: undefined, - employee_id: undefined, - display_name: undefined, - locale: undefined, - user_id: undefined, - }); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - structuredContent: { request_id: 'r1', response: 'hello' }, - }), - }), - ); - }); - - it('handles get_session_status tool call', async () => { - mockGetSessionById.mockResolvedValue({ session_id: 's1', status: 'active' }); - - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '4', - method: 'tools/call', - params: { - name: 'get_session_status', - arguments: { session_id: 's1' }, - }, - }); - await handleMcpRequest(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - structuredContent: { session_id: 's1', status: 'active' }, - }), - }), - ); - }); - - it('returns not_found for missing session', async () => { - mockGetSessionById.mockResolvedValue(null); - - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '5', - method: 'tools/call', - params: { - name: 'get_session_status', - arguments: { session_id: 'missing' }, - }, - }); - await handleMcpRequest(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - structuredContent: { session_id: 'missing', status: 'not_found' }, - }), - }), - ); - }); - - it('handles list_agents tool call', async () => { - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '6', - method: 'tools/call', - params: { - name: 'list_agents', - arguments: {}, - }, - }); - await handleMcpRequest(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - structuredContent: expect.objectContaining({ - agents: expect.arrayContaining([expect.objectContaining({ agent_id: 'default' })]), - }), - }), - }), - ); - }); - - it('returns error for unknown tool', async () => { - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '7', - method: 'tools/call', - params: { - name: 'nonexistent_tool', - arguments: {}, - }, - }); - await handleMcpRequest(req, res); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ code: -32602 }), - }), - ); - }); - - it('returns 500 when handler throws', async () => { - mockHandleInternalRequest.mockRejectedValue(new Error('boom')); - - const { req, res } = mockReqRes({ - jsonrpc: '2.0', - id: '8', - method: 'tools/call', - params: { - name: 'handle_message', - arguments: { input: 'Hello' }, - }, - }); - await handleMcpRequest(req, res); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ code: -32603 }), - }), - ); - }); -}); - -describe('mcpMiddleware', () => { - it('handles /mcp POST requests', async () => { - const { req, res } = mockReqRes( - { jsonrpc: '2.0', id: '1', method: 'tools/list' }, - '/mcp', - 'POST', - ); - const next = vi.fn(); - await mcpMiddleware(req, res, next); - expect(next).not.toHaveBeenCalled(); - }); - - it('calls next for non-/mcp paths', async () => { - const { req, res } = mockReqRes({}, '/v1/request', 'POST'); - const next = vi.fn(); - await mcpMiddleware(req, res, next); - expect(next).toHaveBeenCalled(); - }); - - it('calls next for non-POST on /mcp', async () => { - const { req, res } = mockReqRes({}, '/mcp', 'GET'); - const next = vi.fn(); - await mcpMiddleware(req, res, next); - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/orchestrator.mcp.test.ts b/tests/unit/orchestrator.mcp.test.ts deleted file mode 100644 index b6ac5e6..0000000 --- a/tests/unit/orchestrator.mcp.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { Request, Response } from 'express'; - -vi.mock('../../src/observability/logger.js', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - }, -})); - -const { sseHandler, messageHandler, sendToClient, closeSseConnection, getActiveConnectionCount } = - await import('../../src/mcp-server/orchestrator.mcp.js'); - -const trackedSessionIds = new Set(); - -beforeEach(() => { - for (const sessionId of trackedSessionIds) { - closeSseConnection(sessionId); - } - trackedSessionIds.clear(); -}); - -function createMockReqRes(query: Record = {}, body: unknown = {}) { - const listeners: Record void> = {}; - - const req = { - query, - body, - on: vi.fn((event: string, handler: () => void) => { - listeners[event] = handler; - }), - triggerClose: () => { - listeners['close']?.(); - }, - } as unknown as Request & { triggerClose: () => void }; - - const written: string[] = []; - const headers: Record = {}; - - const res = { - setHeader: vi.fn((key: string, value: string) => { - headers[key] = value; - }), - write: vi.fn((data: string) => { - written.push(data); - }), - end: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - } as unknown as Response & { _written: string[]; _headers: Record }; - - Object.defineProperty(res, '_written', { get: () => written }); - Object.defineProperty(res, '_headers', { get: () => headers }); - - return { req, res }; -} - -describe('sseHandler', () => { - it('establishes SSE connection with headers', async () => { - const sessionId = 'sess-headers'; - trackedSessionIds.add(sessionId); - const { req, res } = createMockReqRes({ sessionId }); - await sseHandler(req, res); - - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/event-stream'); - expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); - expect(res.setHeader).toHaveBeenCalledWith('Connection', 'keep-alive'); - expect(res.setHeader).toHaveBeenCalledWith('X-Accel-Buffering', 'no'); - }); - - it('sends initial connected event with sessionId', async () => { - const sessionId = 'sess-connected'; - trackedSessionIds.add(sessionId); - const { req, res } = createMockReqRes({ sessionId }); - await sseHandler(req, res); - - expect(res.write).toHaveBeenCalledTimes(1); - const writeCalls = (res.write as unknown as { mock: { calls: string[][] } }).mock.calls; - const written = writeCalls[0]![0] as string; - expect(written).toContain('connected'); - }); - - it('uses query sessionId when provided', async () => { - trackedSessionIds.add('my-session'); - const { req, res } = createMockReqRes({ sessionId: 'my-session' }); - await sseHandler(req, res); - - const writeCalls = (res.write as unknown as { mock: { calls: string[][] } }).mock.calls; - const written = writeCalls[0]![0] as string; - expect(written).toContain('my-session'); - }); - - it('removes connection on client close', async () => { - const sessionId = 'sess-close-on-client'; - trackedSessionIds.add(sessionId); - const { req, res } = createMockReqRes({ sessionId }); - await sseHandler(req, res); - expect(getActiveConnectionCount()).toBe(1); - - req.triggerClose(); - expect(getActiveConnectionCount()).toBe(0); - }); -}); - -describe('sendToClient', () => { - it('returns false when no connection exists', () => { - expect(sendToClient('nonexistent', { test: true })).toBe(false); - }); - - it('sends message to connected client', async () => { - trackedSessionIds.add('sess-1'); - const { req, res } = createMockReqRes({ sessionId: 'sess-1' }); - await sseHandler(req, res); - - const result = sendToClient('sess-1', { type: 'response', data: 'hello' }); - expect(result).toBe(true); - expect(res.write).toHaveBeenCalledTimes(2); - }); -}); - -describe('messageHandler', () => { - it('returns 400 when sessionId is missing', async () => { - const { req, res } = createMockReqRes({}, { type: 'message' }); - await messageHandler(req, res); - expect(res.status).toHaveBeenCalledWith(400); - }); - - it('delivers message to SSE client', async () => { - trackedSessionIds.add('sess-1'); - const { req: sseReq, res: sseRes } = createMockReqRes({ sessionId: 'sess-1' }); - await sseHandler(sseReq, sseRes); - - const { req, res } = createMockReqRes({ sessionId: 'sess-1' }, { payload: 'data' }); - await messageHandler(req, res); - - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - delivered: true, - sessionId: 'sess-1', - }), - ); - }); - - it('returns delivered: false when no SSE connection', async () => { - const { req, res } = createMockReqRes({ sessionId: 'no-conn' }, { payload: 'data' }); - await messageHandler(req, res); - - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - delivered: false, - }), - ); - }); -}); - -describe('closeSseConnection', () => { - it('returns false for nonexistent connection', () => { - expect(closeSseConnection('nonexistent')).toBe(false); - }); - - it('closes and removes existing connection', async () => { - trackedSessionIds.add('sess-close'); - const { req, res } = createMockReqRes({ sessionId: 'sess-close' }); - await sseHandler(req, res); - - expect(closeSseConnection('sess-close')).toBe(true); - expect(res.end).toHaveBeenCalled(); - expect(getActiveConnectionCount()).toBe(0); - }); -}); diff --git a/tests/unit/prompt.builder.test.ts b/tests/unit/prompt.builder.test.ts deleted file mode 100644 index 23780b3..0000000 --- a/tests/unit/prompt.builder.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - buildClassifierPrompt, - parseClassifierOutput, -} from '../../src/classifier/prompt.builder.js'; -import type { AgentRegistry } from '../../src/registry/types.js'; - -const mockRegistry: AgentRegistry = [ - { - agent_id: 'default', - display_name: 'Default Agent', - description: 'Handles general requests', - endpoint: 'https://default.example.com', - type: 'mcp', - is_default: true, - confidence_threshold: 0, - clarification_required: false, - examples: ['General query'], - clarification_context: 'I can help with general tasks', - }, - { - agent_id: 'specialist', - display_name: 'Specialist Agent', - description: 'Handles specialized tasks', - endpoint: 'https://specialist.example.com', - type: 'mcp', - is_default: false, - confidence_threshold: 0.7, - clarification_required: false, - examples: ['Specialist query one', 'Specialist query two'], - }, -]; - -describe('buildClassifierPrompt', () => { - it('should include system prompt text', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).toContain('intent classifier'); - expect(prompt).toContain('agent_id'); - expect(prompt).toContain('confidence'); - }); - - it('should include all agent sections', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).toContain('Default Agent'); - expect(prompt).toContain('default'); - expect(prompt).toContain('Specialist Agent'); - expect(prompt).toContain('specialist'); - }); - - it('should include agent descriptions', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).toContain('Handles general requests'); - expect(prompt).toContain('Handles specialized tasks'); - }); - - it('should include agent examples', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).toContain('General query'); - expect(prompt).toContain('Specialist query one'); - expect(prompt).toContain('Specialist query two'); - }); - - it('should include user input', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Reset my password'); - expect(prompt).toContain('Reset my password'); - }); - - it('should include language hint when detectedLanguage is provided', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hola', 'es'); - expect(prompt).toContain('prefer es'); - }); - - it('should not include language hint when detectedLanguage is undefined', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).not.toContain('prefer'); - }); - - it('should include clarification_context when present', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).toContain('Clarification context: I can help with general tasks'); - }); - - it('should not include clarification_context when absent', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - const specialistSection = prompt.split('Specialist Agent')[1]; - expect(specialistSection).not.toContain('Clarification context'); - }); - - it('should include JSON response schema', () => { - const prompt = buildClassifierPrompt(mockRegistry, 'Hello'); - expect(prompt).toContain('"agent_id"'); - expect(prompt).toContain('"confidence"'); - expect(prompt).toContain('"ambiguous"'); - expect(prompt).toContain('"detected_language"'); - expect(prompt).toContain('"intent_summary"'); - expect(prompt).toContain('"entities"'); - }); -}); - -describe('parseClassifierOutput', () => { - it('should parse valid JSON output', () => { - const json = JSON.stringify({ - agent_id: 'specialist', - confidence: 0.85, - ambiguous: false, - detected_language: 'en', - intent_summary: 'User wants a specialist', - entities: { key: 'value' }, - }); - - const result = parseClassifierOutput(json); - expect(result.agent_id).toBe('specialist'); - expect(result.confidence).toBe(0.85); - expect(result.ambiguous).toBe(false); - expect(result.detected_language).toBe('en'); - expect(result.intent_summary).toBe('User wants a specialist'); - expect(result.entities).toEqual({ key: 'value' }); - }); - - it('should parse JSON wrapped in markdown code block', () => { - const output = - '```json\n{"agent_id":"default","confidence":0.5,"detected_language":"en","intent_summary":"test","entities":{}}\n```'; - const result = parseClassifierOutput(output); - expect(result.agent_id).toBe('default'); - expect(result.confidence).toBe(0.5); - }); - - it('should default ambiguous to false when missing', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: 0.5, - detected_language: 'en', - intent_summary: 'test', - }); - - const result = parseClassifierOutput(json); - expect(result.ambiguous).toBe(false); - }); - - it('should default entities to empty object when missing', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: 0.5, - detected_language: 'en', - intent_summary: 'test', - }); - - const result = parseClassifierOutput(json); - expect(result.entities).toEqual({}); - }); - - it('should throw on missing agent_id', () => { - const json = JSON.stringify({ - confidence: 0.5, - detected_language: 'en', - intent_summary: 'test', - }); - - expect(() => parseClassifierOutput(json)).toThrow('Missing or invalid agent_id'); - }); - - it('should throw on invalid confidence type', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: 'high', - detected_language: 'en', - intent_summary: 'test', - }); - - expect(() => parseClassifierOutput(json)).toThrow('Missing or invalid confidence'); - }); - - it('should throw on confidence below 0', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: -0.1, - detected_language: 'en', - intent_summary: 'test', - }); - - expect(() => parseClassifierOutput(json)).toThrow('Missing or invalid confidence'); - }); - - it('should throw on confidence above 1', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: 1.5, - detected_language: 'en', - intent_summary: 'test', - }); - - expect(() => parseClassifierOutput(json)).toThrow('Missing or invalid confidence'); - }); - - it('should throw on missing detected_language', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: 0.5, - intent_summary: 'test', - }); - - expect(() => parseClassifierOutput(json)).toThrow('Missing or invalid detected_language'); - }); - - it('should throw on missing intent_summary', () => { - const json = JSON.stringify({ - agent_id: 'default', - confidence: 0.5, - detected_language: 'en', - }); - - expect(() => parseClassifierOutput(json)).toThrow('Missing or invalid intent_summary'); - }); - - it('should throw on invalid JSON', () => { - expect(() => parseClassifierOutput('not json')).toThrow(); - }); - - it('should handle JSON with surrounding whitespace', () => { - const json = ` \n {"agent_id":"default","confidence":0.5,"detected_language":"en","intent_summary":"test"} \n `; - const result = parseClassifierOutput(json); - expect(result.agent_id).toBe('default'); - }); -}); diff --git a/tests/unit/rateLimiter.middleware.test.ts b/tests/unit/rateLimiter.middleware.test.ts deleted file mode 100644 index 9346fa3..0000000 --- a/tests/unit/rateLimiter.middleware.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - ENABLE_RATE_LIMITING: true, - RATE_LIMIT_WINDOW_MS: 60000, - RATE_LIMIT_MAX_REQUESTS: 100, - }, -})); - -const { rateLimiterMiddleware, clearRateLimitBuckets, getBucketState } = - await import('../../src/gateway/rateLimiter.middleware.js'); - -const mockResponse = (): Partial => { - const res: Partial = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - }; - return res; -}; - -describe('rateLimiterMiddleware', () => { - beforeEach(() => { - clearRateLimitBuckets(); - vi.clearAllMocks(); - }); - - afterEach(() => { - clearRateLimitBuckets(); - }); - - it('allows request when tokens available', () => { - const req = { - headers: { 'x-api-key': 'test-client' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res = mockResponse(); - const next = vi.fn(); - - rateLimiterMiddleware(req, res as import('express').Response, next); - - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalledWith(429); - }); - - it('blocks when tokens exhausted', () => { - const req = { - headers: { 'x-api-key': 'rate-limit-test' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res = mockResponse(); - const next = vi.fn(); - - for (let i = 0; i < 100; i++) { - const n = vi.fn(); - rateLimiterMiddleware(req, res as import('express').Response, n); - } - - rateLimiterMiddleware(req, res as import('express').Response, next); - - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(429); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Rate limit exceeded', - retry_after: expect.any(Number), - }), - ); - }); - - it('sets rate limit headers', () => { - const req = { - headers: { 'x-api-key': 'header-test' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res = mockResponse(); - const next = vi.fn(); - - rateLimiterMiddleware(req, res as import('express').Response, next); - - expect(res.set).toHaveBeenCalledWith('X-RateLimit-Limit', '100'); - expect(res.set).toHaveBeenCalledWith('X-RateLimit-Remaining', expect.any(String)); - expect(res.set).toHaveBeenCalledWith('X-RateLimit-Reset', expect.any(String)); - }); - - it('uses API key as client identifier', () => { - const req = { - headers: { 'x-api-key': 'api-key-client' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res = mockResponse(); - const next = vi.fn(); - - rateLimiterMiddleware(req, res as import('express').Response, next); - rateLimiterMiddleware(req, res as import('express').Response, next); - - expect(next).toHaveBeenCalledTimes(2); - const state = getBucketState('key:api-key-client'); - expect(state).toBeDefined(); - }); - - it('uses X-Forwarded-For IP as fallback identifier', () => { - const req = { - headers: { 'x-forwarded-for': '192.168.1.1, 10.0.0.1' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res = mockResponse(); - const next = vi.fn(); - - rateLimiterMiddleware(req, res as import('express').Response, next); - - expect(next).toHaveBeenCalled(); - const state = getBucketState('ip:192.168.1.1'); - expect(state).toBeDefined(); - }); -}); - -describe('bucket state', () => { - beforeEach(() => { - clearRateLimitBuckets(); - }); - - afterEach(() => { - clearRateLimitBuckets(); - }); - - it('getBucketState returns undefined for unknown client', () => { - const state = getBucketState('unknown-client'); - expect(state).toBeUndefined(); - }); - - it('clearRateLimitBuckets clears all buckets', () => { - const req = { - headers: { 'x-api-key': 'clear-test' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res = mockResponse(); - const next = vi.fn(); - rateLimiterMiddleware(req, res as import('express').Response, next); - - clearRateLimitBuckets(); - - const state = getBucketState('key:clear-test'); - expect(state).toBeUndefined(); - }); - - it('rate limit headers show remaining tokens decreasing', () => { - const req = { - headers: { 'x-api-key': 'decreasing-tokens' }, - ip: '127.0.0.1', - path: '/v1/request', - } as Partial as import('express').Request; - const res1 = mockResponse(); - const next1 = vi.fn(); - rateLimiterMiddleware(req, res1 as import('express').Response, next1); - - const res2 = mockResponse(); - const next2 = vi.fn(); - rateLimiterMiddleware(req, res2 as import('express').Response, next2); - - expect(res1.set).toHaveBeenCalled(); - expect(res2.set).toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/registry.extensibility.test.ts b/tests/unit/registry.extensibility.test.ts deleted file mode 100644 index cbc23e1..0000000 --- a/tests/unit/registry.extensibility.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - AGENT_REGISTRY_DIR: '/tmp/test-agents-extensibility', - }, -})); - -vi.mock('../../src/classifier/classifier.service.js', () => ({ - classifierService: { - classify: vi.fn(), - }, -})); - -const { loadRegistry, registryState, reloadRegistry } = - await import('../../src/registry/registry.loader.js'); - -describe('Registry Extensibility Contract', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'registry-extensibility-')); - vi.mocked(await import('../../src/config/env.js')).env.AGENT_REGISTRY_DIR = tmpDir; - registryState.swap([]); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - }); - - describe('Agent Addition Without Code Changes', () => { - it('can add a new agent by creating a YAML file', async () => { - const defaultYaml = ` -agent_id: "default" -display_name: "Default Agent" -description: "Default fallback agent" -endpoint: "https://default.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "General query" -`; - await fs.writeFile(path.join(tmpDir, 'default.yaml'), defaultYaml); - - let registry = await loadRegistry(); - expect(registry).toHaveLength(1); - expect(registry[0]!.agent_id).toBe('default'); - - const newAgentYaml = ` -agent_id: "new-specialist" -display_name: "New Specialist" -description: "A new specialist agent for testing extensibility" -endpoint: "https://new-specialist.example.com" -type: mcp -is_default: false -confidence_threshold: 0.7 -clarification_required: false -examples: - - "Add a new task" - - "Create something new" - - "New functionality request" -`; - await fs.writeFile(path.join(tmpDir, 'new-specialist.yaml'), newAgentYaml); - - registry = await loadRegistry(); - expect(registry).toHaveLength(2); - - const newAgent = registry.find((a) => a.agent_id === 'new-specialist'); - expect(newAgent).toBeDefined(); - expect(newAgent?.display_name).toBe('New Specialist'); - expect(newAgent?.confidence_threshold).toBe(0.7); - }); - - it('registry reload picks up new agents', async () => { - const defaultYaml = ` -agent_id: "default" -display_name: "Default" -description: "Default" -endpoint: "https://default.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "General" -`; - await fs.writeFile(path.join(tmpDir, 'default.yaml'), defaultYaml); - - let result = await reloadRegistry(); - expect(result.success).toBe(true); - expect(result.agentCount).toBe(1); - - const specialistYaml = ` -agent_id: "specialist" -display_name: "Specialist" -description: "Specialist" -endpoint: "https://specialist.example.com" -type: mcp -is_default: false -confidence_threshold: 0.7 -clarification_required: false -examples: - - "Specialized task" -`; - await fs.writeFile(path.join(tmpDir, 'specialist.yaml'), specialistYaml); - - result = await reloadRegistry(); - expect(result.success).toBe(true); - expect(result.agentCount).toBe(2); - expect(result.agentIds).toContain('specialist'); - }); - }); - - describe('Classifier Prompt Integration', () => { - it('new agent examples are included in registry', async () => { - const defaultYaml = ` -agent_id: "default" -display_name: "Default" -description: "Default agent" -endpoint: "https://default.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "General question" -`; - await fs.writeFile(path.join(tmpDir, 'default.yaml'), defaultYaml); - - const newAgentYaml = ` -agent_id: "weather" -display_name: "Weather Agent" -description: "Handles weather-related queries" -endpoint: "https://weather.example.com" -type: mcp -is_default: false -confidence_threshold: 0.6 -clarification_required: false -examples: - - "What's the weather in Boston?" - - "Will it rain tomorrow?" - - "Temperature forecast for next week" -`; - await fs.writeFile(path.join(tmpDir, 'weather.yaml'), newAgentYaml); - - const registry = await loadRegistry(); - - const weatherAgent = registry.find((a) => a.agent_id === 'weather'); - expect(weatherAgent).toBeDefined(); - expect(weatherAgent?.examples).toHaveLength(3); - expect(weatherAgent?.examples).toContain("What's the weather in Boston?"); - }); - - it('classifier prompt builder can access all agent descriptions', async () => { - const defaultYaml = ` -agent_id: "default" -display_name: "Default" -description: "Handles general requests" -endpoint: "https://default.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Help me" -`; - await fs.writeFile(path.join(tmpDir, 'default.yaml'), defaultYaml); - - const agent1Yaml = ` -agent_id: "agent-1" -display_name: "Agent One" -description: "First specialist agent" -endpoint: "https://agent1.example.com" -type: mcp -is_default: false -confidence_threshold: 0.7 -clarification_required: false -examples: - - "Agent 1 task" -`; - await fs.writeFile(path.join(tmpDir, 'agent1.yaml'), agent1Yaml); - - const agent2Yaml = ` -agent_id: "agent-2" -display_name: "Agent Two" -description: "Second specialist agent" -endpoint: "https://agent2.example.com" -type: mcp -is_default: false -confidence_threshold: 0.8 -clarification_required: true -examples: - - "Agent 2 task" -`; - await fs.writeFile(path.join(tmpDir, 'agent2.yaml'), agent2Yaml); - - const registry = await loadRegistry(); - - expect(registry).toHaveLength(3); - expect(registry.find((a) => a.agent_id === 'agent-1')).toBeDefined(); - expect(registry.find((a) => a.agent_id === 'agent-2')).toBeDefined(); - - registryState.swap(registry); - expect(registryState.getAgentIds()).toHaveLength(3); - }); - }); - - describe('Atomic Swap Behavior', () => { - it('readers see consistent snapshot during reload', async () => { - const defaultYaml = ` -agent_id: "default" -display_name: "Default" -description: "Default" -endpoint: "https://default.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Help" -`; - await fs.writeFile(path.join(tmpDir, 'default.yaml'), defaultYaml); - - await reloadRegistry(); - const initialSnapshot = registryState.registry; - - const newAgentYaml = ` -agent_id: "new-agent" -display_name: "New Agent" -description: "New agent added later" -endpoint: "https://new.example.com" -type: mcp -is_default: false -confidence_threshold: 0.6 -clarification_required: false -examples: - - "New task" -`; - await fs.writeFile(path.join(tmpDir, 'new-agent.yaml'), newAgentYaml); - - await reloadRegistry(); - const newSnapshot = registryState.registry; - - expect(initialSnapshot).toHaveLength(1); - expect(newSnapshot).toHaveLength(2); - - expect(initialSnapshot?.find((a) => a.agent_id === 'new-agent')).toBeUndefined(); - expect(newSnapshot?.find((a) => a.agent_id === 'new-agent')).toBeDefined(); - }); - }); - - describe('Validation Invariants', () => { - it('rejects new agent with duplicate ID', async () => { - const yaml1 = ` -agent_id: "unique-id" -display_name: "First" -description: "First agent" -endpoint: "https://first.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Test" -`; - await fs.writeFile(path.join(tmpDir, 'first.yaml'), yaml1); - await reloadRegistry(); - - const duplicateYaml = ` -agent_id: "unique-id" -display_name: "Duplicate" -description: "Duplicate ID agent" -endpoint: "https://dup.example.com" -type: mcp -is_default: false -confidence_threshold: 0.7 -clarification_required: false -examples: - - "Test 2" -`; - await fs.writeFile(path.join(tmpDir, 'dup.yaml'), duplicateYaml); - - const result = await reloadRegistry(); - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('maintains exactly one default agent', async () => { - const default1Yaml = ` -agent_id: "default-1" -display_name: "Default One" -description: "First default" -endpoint: "https://default1.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Help" -`; - await fs.writeFile(path.join(tmpDir, 'default1.yaml'), default1Yaml); - await reloadRegistry(); - - const default2Yaml = ` -agent_id: "default-2" -display_name: "Default Two" -description: "Second default (should fail)" -endpoint: "https://default2.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Help 2" -`; - await fs.writeFile(path.join(tmpDir, 'default2.yaml'), default2Yaml); - - const result = await reloadRegistry(); - expect(result.success).toBe(false); - expect(result.errors.some((e) => e.toLowerCase().includes('default'))).toBe(true); - }); - }); -}); diff --git a/tests/unit/registry.loader.test.ts b/tests/unit/registry.loader.test.ts deleted file mode 100644 index 4363a2f..0000000 --- a/tests/unit/registry.loader.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - AGENT_REGISTRY_DIR: '/tmp/test-agents', - }, -})); - -const { loadRegistry, registryState, reloadRegistry } = - await import('../../src/registry/registry.loader.js'); - -const validDefaultYaml = ` -agent_id: "default" -display_name: "Default Agent" -description: "Default fallback agent" -endpoint: "https://default.example.com" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "General query" -`; - -const validSpecialistYaml = ` -agent_id: "specialist" -display_name: "Specialist Agent" -description: "Specialist agent" -endpoint: "https://specialist.example.com" -type: mcp -is_default: false -confidence_threshold: 0.7 -clarification_required: false -examples: - - "Specialist query" -`; - -describe('loadRegistry', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'registry-test-')); - vi.mocked(await import('../../src/config/env.js')).env.AGENT_REGISTRY_DIR = tmpDir; - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - }); - - it('loads a valid registry with default agent', async () => { - await fs.writeFile(path.join(tmpDir, 'default.yaml'), validDefaultYaml); - const registry = await loadRegistry(); - expect(registry.length).toBe(1); - expect(registry[0]!.agent_id).toBe('default'); - expect(registry[0]!.is_default).toBe(true); - }); - - it('loads multiple agents', async () => { - await fs.writeFile(path.join(tmpDir, 'default.yaml'), validDefaultYaml); - await fs.writeFile(path.join(tmpDir, 'specialist.yaml'), validSpecialistYaml); - const registry = await loadRegistry(); - expect(registry).toHaveLength(2); - expect(registry.map((a) => a.agent_id).sort()).toEqual(['default', 'specialist']); - }); - - it('throws when no YAML files found', async () => { - await expect(loadRegistry()).rejects.toThrow('No agent YAML files found'); - }); - - it('throws on YAML parse error', async () => { - await fs.writeFile(path.join(tmpDir, 'bad.yaml'), '{{invalid yaml'); - await expect(loadRegistry()).rejects.toThrow(); - }); - - it('throws on schema validation error', async () => { - const badYaml = ` -agent_id: "bad" -display_name: "Bad" -description: "Missing fields" -endpoint: "not-a-url" -type: mcp -is_default: false -confidence_threshold: 5 -clarification_required: false -examples: [] -`; - await fs.writeFile(path.join(tmpDir, 'bad.yaml'), badYaml); - await expect(loadRegistry()).rejects.toThrow(); - }); - - it('throws on multiple default agents', async () => { - const dup = validDefaultYaml.replace('"default"', '"default2"'); - await fs.writeFile(path.join(tmpDir, 'a.yaml'), validDefaultYaml); - await fs.writeFile(path.join(tmpDir, 'b.yaml'), dup); - await expect(loadRegistry()).rejects.toThrow(); - }); - - it('expands environment variables in YAML', async () => { - process.env.TEST_AGENT_ENDPOINT = 'https://from-env.example.com'; - const yamlWithEnv = ` -agent_id: "env-agent" -display_name: "Env Agent" -description: "Agent with env var endpoint" -endpoint: "\${TEST_AGENT_ENDPOINT}" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Test" -`; - await fs.writeFile(path.join(tmpDir, 'env.yaml'), yamlWithEnv); - const registry = await loadRegistry(); - expect(registry[0]!.endpoint).toBe('https://from-env.example.com'); - delete process.env.TEST_AGENT_ENDPOINT; - }); - - it('uses default value for env var when provided', async () => { - delete process.env.NONEXISTENT_VAR; - const yamlWithDefault = ` -agent_id: "default-val-agent" -display_name: "Default Val Agent" -description: "Agent with default env var" -endpoint: "\${NONEXISTENT_VAR:-https://fallback.example.com}" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Test" -`; - await fs.writeFile(path.join(tmpDir, 'def.yaml'), yamlWithDefault); - const registry = await loadRegistry(); - expect(registry[0]!.endpoint).toBe('https://fallback.example.com'); - }); - - it('throws on unset env var without default', async () => { - delete process.env.TOTALLY_MISSING_VAR; - const yamlNoDefault = ` -agent_id: "unset-agent" -display_name: "Unset Agent" -description: "Agent with unset env var" -endpoint: "\${TOTALLY_MISSING_VAR}" -type: mcp -is_default: true -confidence_threshold: 0 -clarification_required: false -examples: - - "Test" -`; - await fs.writeFile(path.join(tmpDir, 'unset.yaml'), yamlNoDefault); - await expect(loadRegistry()).rejects.toThrow(); - }); - - it('handles .yml extension', async () => { - await fs.writeFile(path.join(tmpDir, 'agent.yml'), validDefaultYaml); - const registry = await loadRegistry(); - expect(registry).toHaveLength(1); - }); -}); - -describe('RegistryState', () => { - it('swaps registry and finds agents', () => { - const registry = [ - { - agent_id: 'a', - display_name: 'A', - description: 'A', - endpoint: 'https://a.example.com', - type: 'mcp' as const, - is_default: true, - confidence_threshold: 0, - clarification_required: false, - examples: [], - }, - { - agent_id: 'b', - display_name: 'B', - description: 'B', - endpoint: 'https://b.example.com', - type: 'mcp' as const, - is_default: false, - confidence_threshold: 0.5, - clarification_required: false, - examples: [], - }, - ]; - - registryState.swap(registry); - expect(registryState.isLoaded).toBe(true); - expect(registryState.getAgent('a')).toBeDefined(); - expect(registryState.getAgent('b')).toBeDefined(); - expect(registryState.getAgent('c')).toBeUndefined(); - expect(registryState.defaultAgent?.agent_id).toBe('a'); - expect(registryState.getAgentIds()).toEqual(['a', 'b']); - expect(registryState.lastLoadTime).toBeGreaterThan(0); - expect(registryState.loadError).toBeNull(); - }); - - it('sets load error', () => { - registryState.setError(new Error('test error')); - expect(registryState.loadError).not.toBeNull(); - expect(registryState.loadError?.message).toBe('test error'); - }); -}); - -describe('reloadRegistry', () => { - it('returns result with success false on load failure', async () => { - const result = await reloadRegistry(); - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/unit/router.service.test.ts b/tests/unit/router.service.test.ts deleted file mode 100644 index b6106bc..0000000 --- a/tests/unit/router.service.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { AgentConfig } from '../../src/registry/types.js'; - -const mockClientSendMessage = vi.fn(); -const mockRecordSuccess = vi.fn(); -const mockRecordFailure = vi.fn(); -const mockRecordDuration = vi.fn(); -const mockRecordError = vi.fn(); -const mockCanCall = vi.fn(); - -vi.mock('../../src/config/env.js', () => ({ - env: { - ENABLE_CIRCUIT_BREAKER: true, - }, -})); - -vi.mock('../../src/router/mcp.client.js', () => ({ - mcpClientFactory: { - getClient: () => ({ - sendMessage: mockClientSendMessage, - }), - }, -})); - -vi.mock('../../src/observability/metrics.js', () => ({ - recordAgentDispatchDuration: mockRecordDuration, - recordAgentDispatchError: mockRecordError, -})); - -vi.mock('../../src/utils/circuitBreaker.js', () => ({ - circuitBreaker: { - canCall: mockCanCall, - recordSuccess: mockRecordSuccess, - recordFailure: mockRecordFailure, - }, -})); - -const { - dispatchToAgent, - buildTurnEntry, - formatAgentResponse, - shouldCloseSession, - getUpdatedWorkflowState, -} = await import('../../src/router/router.service.js'); - -const mockAgent: AgentConfig = { - agent_id: 'test-agent', - display_name: 'Test Agent', - description: 'Test', - endpoint: 'https://test.example.com', - type: 'mcp', - is_default: false, - confidence_threshold: 0.7, - clarification_required: false, - examples: [], -}; - -describe('dispatchToAgent', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockCanCall.mockReturnValue(true); - }); - - it('dispatches and returns agent response', async () => { - mockClientSendMessage.mockResolvedValue({ - content: 'Here is your answer', - workflow_complete: true, - workflow_state: { step: 'done' }, - }); - - const result = await dispatchToAgent(mockAgent, { - sessionId: 'sess-1', - employeeId: 'emp-1', - displayName: 'Test User', - rawInput: 'Hello', - intentSummary: 'Greeting', - entities: {}, - detectedLanguage: 'en', - turnHistory: [], - workflowState: {}, - }); - - expect(result.content).toBe('Here is your answer'); - expect(result.workflow_complete).toBe(true); - expect(result.workflow_state).toEqual({ step: 'done' }); - }); - - it('throws on circuit breaker open', async () => { - mockCanCall.mockReturnValue(false); - - await expect( - dispatchToAgent(mockAgent, { - sessionId: 'sess-1', - employeeId: 'emp-1', - displayName: 'Test User', - rawInput: 'Hello', - intentSummary: 'Greeting', - entities: {}, - detectedLanguage: 'en', - turnHistory: [], - workflowState: {}, - }), - ).rejects.toThrow('Circuit breaker OPEN for agent test-agent'); - }); - - it('records success and duration on successful dispatch', async () => { - mockClientSendMessage.mockResolvedValue({ - content: 'Success', - workflow_complete: false, - }); - - await dispatchToAgent(mockAgent, { - sessionId: 'sess-1', - employeeId: 'emp-1', - displayName: 'Test User', - rawInput: 'Hello', - intentSummary: 'Greeting', - entities: {}, - detectedLanguage: 'en', - turnHistory: [], - workflowState: {}, - }); - - expect(mockRecordSuccess).toHaveBeenCalledWith('test-agent'); - expect(mockRecordDuration).toHaveBeenCalledWith('test-agent', expect.any(Number)); - }); - - it('records failure and error on dispatch failure', async () => { - mockClientSendMessage.mockRejectedValue(new Error('Agent unavailable')); - - await expect( - dispatchToAgent(mockAgent, { - sessionId: 'sess-1', - employeeId: 'emp-1', - displayName: 'Test User', - rawInput: 'Hello', - intentSummary: 'Greeting', - entities: {}, - detectedLanguage: 'en', - turnHistory: [], - workflowState: {}, - }), - ).rejects.toThrow('Agent unavailable'); - - expect(mockRecordFailure).toHaveBeenCalledWith('test-agent'); - expect(mockRecordError).toHaveBeenCalledWith('test-agent', 'Error'); - }); - - it('throws on agent error', async () => { - mockClientSendMessage.mockRejectedValue(new Error('Agent unavailable')); - - await expect( - dispatchToAgent(mockAgent, { - sessionId: 'sess-1', - employeeId: 'emp-1', - displayName: 'Test User', - rawInput: 'Hello', - intentSummary: 'Greeting', - entities: {}, - detectedLanguage: 'en', - turnHistory: [], - workflowState: {}, - }), - ).rejects.toThrow('Agent unavailable'); - }); -}); - -describe('buildTurnEntry', () => { - it('builds a user turn entry', () => { - const entry = buildTurnEntry('user', 'Hello'); - expect(entry.role).toBe('user'); - expect(entry.content).toBe('Hello'); - expect(entry.timestamp).toBeDefined(); - expect(entry.intent_summary).toBeUndefined(); - }); - - it('builds an agent turn entry', () => { - const entry = buildTurnEntry('agent', 'Response text'); - expect(entry.role).toBe('agent'); - expect(entry.content).toBe('Response text'); - }); - - it('builds a turn entry with intent_summary', () => { - const entry = buildTurnEntry('user', 'Hello', 'Greeting'); - expect(entry.intent_summary).toBe('Greeting'); - }); - - it('generates ISO timestamp', () => { - const before = Date.now(); - const entry = buildTurnEntry('user', 'test'); - const after = Date.now(); - expect(new Date(entry.timestamp).getTime()).toBeGreaterThanOrEqual(before); - expect(new Date(entry.timestamp).getTime()).toBeLessThanOrEqual(after); - }); -}); - -describe('formatAgentResponse', () => { - it('returns the content string', () => { - expect(formatAgentResponse({ content: 'hello', workflow_complete: true })).toBe('hello'); - }); - - it('returns full content string', () => { - const response = { content: 'This is a longer response text', workflow_complete: false }; - expect(formatAgentResponse(response)).toBe('This is a longer response text'); - }); -}); - -describe('shouldCloseSession', () => { - it('returns true when workflow_complete is true', () => { - expect(shouldCloseSession({ content: 'done', workflow_complete: true })).toBe(true); - }); - - it('returns false when workflow_complete is false', () => { - expect(shouldCloseSession({ content: 'more', workflow_complete: false })).toBe(false); - }); - - it('returns true even with empty content when workflow_complete is true', () => { - expect(shouldCloseSession({ content: '', workflow_complete: true })).toBe(true); - }); -}); - -describe('getUpdatedWorkflowState', () => { - it('returns response workflow_state when present', () => { - const current = { step: 'old' }; - const result = getUpdatedWorkflowState(current, { - content: 'ok', - workflow_complete: false, - workflow_state: { step: 'new' }, - }); - expect(result).toEqual({ step: 'new' }); - }); - - it('returns current state when response has no workflow_state', () => { - const current = { step: 'old' }; - const result = getUpdatedWorkflowState(current, { - content: 'ok', - workflow_complete: false, - }); - expect(result).toEqual({ step: 'old' }); - }); - - it('returns current state when response workflow_state is undefined', () => { - const current = { step: 'current' }; - const result = getUpdatedWorkflowState(current, { - content: 'ok', - workflow_complete: false, - workflow_state: undefined, - }); - expect(result).toEqual({ step: 'current' }); - }); - - it('merges nested workflow state correctly', () => { - const current = { step1: 'a', step2: 'b' }; - const result = getUpdatedWorkflowState(current, { - content: 'ok', - workflow_complete: false, - workflow_state: { step2: 'updated', step3: 'new' }, - }); - expect(result).toEqual({ step2: 'updated', step3: 'new' }); - }); -}); diff --git a/tests/unit/session.middleware.test.ts b/tests/unit/session.middleware.test.ts deleted file mode 100644 index dfab536..0000000 --- a/tests/unit/session.middleware.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Session middleware unit tests - * Tests for session lookup, bypass_classifier flag, and error handling - */ - -import type { Request, Response, NextFunction } from 'express'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Mock the session service -const mockGetActiveSession = vi.fn(); - -vi.mock('../../src/session/session.service.js', () => ({ - getActiveSession: mockGetActiveSession, -})); - -// Import after mocking -const { sessionMiddleware } = await import('../../src/session/session.middleware.js'); - -// Mock Express Request and Response -// Using a simple object with only the properties we need, cast to Request -type MockRequest = { - headers: Record; - sessionContext?: { - sessionId: string; - activeAgent: string; - bypassClassifier: boolean; - turnHistory: Array>; - workflowState: Record; - }; -}; - -type RequestWithSessionContext = Request & { - sessionContext?: MockRequest['sessionContext']; -}; - -function createMockRequest(overrides: Partial = {}): RequestWithSessionContext { - const mock: Omit & { - sessionContext?: MockRequest['sessionContext']; - } = { - headers: {}, - ...overrides, - }; - // Don't set sessionContext if undefined - let it remain absent - if (overrides.sessionContext === undefined) { - delete (mock as Record).sessionContext; - } - return mock as unknown as RequestWithSessionContext; -} - -function createMockResponse(): Response { - return { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - set: vi.fn().mockReturnThis(), - } as unknown as Response; -} - -function createMockNext(): NextFunction { - return vi.fn() as unknown as NextFunction; -} - -describe('Session Middleware', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('session lookup', () => { - it('should set bypassClassifier to true when active session found', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'session123', - user_id: 'user123', - status: 'active', - active_agent: 'test-agent', - turn_history: [{ role: 'user', content: 'Hello', timestamp: '2024-01-01T00:00:00.000Z' }], - workflow_state: { step: 'in_progress' }, - }); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext).toEqual( - expect.objectContaining({ - sessionId: 'session123', - activeAgent: 'test-agent', - bypassClassifier: true, - turnHistory: expect.arrayContaining([ - expect.objectContaining({ role: 'user', content: 'Hello' }), - ]), - workflowState: { step: 'in_progress' }, - }), - ); - expect(next).toHaveBeenCalled(); - }); - - it('should set bypassClassifier to false when no active session', async () => { - mockGetActiveSession.mockResolvedValue(null); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext).toEqual( - expect.objectContaining({ - sessionId: '', - activeAgent: '', - bypassClassifier: false, - turnHistory: [], - workflowState: {}, - }), - ); - expect(next).toHaveBeenCalled(); - }); - - it('should set bypassClassifier to false when session status is not active', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'session123', - user_id: 'user123', - status: 'completed', - active_agent: 'test-agent', - turn_history: [], - workflow_state: {}, - }); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext).toEqual( - expect.objectContaining({ - bypassClassifier: false, - }), - ); - }); - - it('should skip session lookup when no user ID header', async () => { - const req = createMockRequest({ - headers: {}, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(mockGetActiveSession).not.toHaveBeenCalled(); - expect(req.sessionContext).toEqual( - expect.objectContaining({ - sessionId: '', - bypassClassifier: false, - }), - ); - expect(next).toHaveBeenCalled(); - }); - }); - - describe('error handling', () => { - it('should fail open on error (continue without session)', async () => { - mockGetActiveSession.mockRejectedValue(new Error('Firestore connection failed')); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - // Should not throw, should continue with empty session context - expect(req.sessionContext).toEqual( - expect.objectContaining({ - sessionId: '', - bypassClassifier: false, - }), - ); - expect(next).toHaveBeenCalled(); - }); - - it('should handle timeout errors gracefully', async () => { - mockGetActiveSession.mockRejectedValue(new Error('Deadline exceeded')); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext).toBeDefined(); - expect(req.sessionContext?.bypassClassifier).toBe(false); - expect(next).toHaveBeenCalled(); - }); - }); - - describe('turn history mapping', () => { - it('should map turn history with intent_summary', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'session123', - user_id: 'user123', - status: 'active', - active_agent: 'test-agent', - turn_history: [ - { - role: 'user', - content: 'What is my balance?', - timestamp: '2024-01-01T00:00:00.000Z', - intent_summary: 'balance_inquiry', - }, - { - role: 'agent', - content: 'Your balance is $100.', - timestamp: '2024-01-01T00:01:00.000Z', - }, - ], - workflow_state: {}, - }); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext?.turnHistory).toEqual([ - expect.objectContaining({ - role: 'user', - content: 'What is my balance?', - intent_summary: 'balance_inquiry', - }), - expect.objectContaining({ - role: 'agent', - content: 'Your balance is $100.', - }), - ]); - }); - - it('should handle empty turn history', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'session123', - user_id: 'user123', - status: 'active', - active_agent: 'test-agent', - turn_history: [], - workflow_state: {}, - }); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext?.turnHistory).toEqual([]); - }); - }); - - describe('workflow state preservation', () => { - it('should preserve workflow state from session', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'session123', - user_id: 'user123', - status: 'active', - active_agent: 'test-agent', - turn_history: [], - workflow_state: { - step: 'verification', - attempts: 2, - data: { key: 'value' }, - }, - }); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext?.workflowState).toEqual({ - step: 'verification', - attempts: 2, - data: { key: 'value' }, - }); - }); - - it('should handle empty workflow state', async () => { - mockGetActiveSession.mockResolvedValue({ - session_id: 'session123', - user_id: 'user123', - status: 'active', - active_agent: 'test-agent', - turn_history: [], - workflow_state: {}, - }); - - const req = createMockRequest({ - headers: { 'x-user-id': 'user123' }, - }); - const res = createMockResponse(); - const next = createMockNext(); - - await sessionMiddleware(req, res, next); - - expect(req.sessionContext?.workflowState).toEqual({}); - }); - }); -}); diff --git a/tests/unit/session.service.test.ts b/tests/unit/session.service.test.ts deleted file mode 100644 index 526e14e..0000000 --- a/tests/unit/session.service.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -/** - * Session service unit tests - * Tests for session creation, retrieval, turn history, TTL, and lifecycle - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Mock Firestore before importing the service -const mockRunTransaction = vi.fn(); -const mockCollection = vi.fn(); -const mockDoc = vi.fn(); -const mockCreate = vi.fn(); -const mockUpdate = vi.fn(); -const mockPublishMessage = vi.fn().mockResolvedValue('message-id'); - -vi.mock('../../src/session/firestoreClient.js', () => ({ - getFirestore: () => ({ - collection: mockCollection, - runTransaction: mockRunTransaction, - }), -})); - -vi.mock('@google-cloud/pubsub', () => ({ - PubSub: class { - topic() { - return { - publishMessage: mockPublishMessage, - }; - } - }, -})); - -vi.mock('../../src/config/env.js', () => ({ - env: { - GOOGLE_CLOUD_PROJECT: 'test-project', - SESSION_TTL_MINUTES: 30, - SESSION_MAX_TURNS: 100, - }, -})); - -vi.mock('@google-cloud/firestore', () => ({ - Timestamp: { - fromDate: (date: Date) => ({ toDate: () => date }), - }, - FieldValue: { - delete: () => '__DELETE__', - }, -})); - -// Import after mocking -const { - createSession, - getActiveSession, - appendTurn, - updateWorkflowState, - closeSession, - resumeSession, -} = await import('../../src/session/session.service.js'); - -describe('Session Service', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('createSession', () => { - it('should create a new session with TTL', async () => { - const mockDocRef = { - create: mockCreate.mockResolvedValue({}), - }; - - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue(mockDocRef), - }); - - const session = await createSession({ - userId: 'user123', - employeeId: 'emp456', - activeAgent: 'test-agent', - }); - - expect(mockCollection).toHaveBeenCalledWith('sessions'); - expect(mockDoc).toHaveBeenCalled(); - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - user_id: 'user123', - employee_id: 'emp456', - status: 'active', - active_agent: 'test-agent', - turn_history: [], - workflow_state: {}, - }), - ); - - expect(session).toEqual( - expect.objectContaining({ - user_id: 'user123', - employee_id: 'emp456', - status: 'active', - active_agent: 'test-agent', - turn_history: [], - workflow_state: {}, - }), - ); - expect(session.session_id).toBeDefined(); - }); - - it('should generate a unique session ID', async () => { - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({ - create: mockCreate.mockResolvedValue({}), - }), - }); - - const session1 = await createSession({ - userId: 'user123', - employeeId: 'emp456', - activeAgent: 'test-agent', - }); - - const session2 = await createSession({ - userId: 'user123', - employeeId: 'emp456', - activeAgent: 'test-agent', - }); - - expect(session1.session_id).not.toBe(session2.session_id); - }); - }); - - describe('getActiveSession', () => { - it('should return null when no active session exists', async () => { - mockCollection.mockReturnValue({ - where: () => ({ - where: () => ({ - where: () => ({ - limit: () => ({ - get: () => Promise.resolve({ empty: true, docs: [] }), - }), - }), - }), - }), - }); - - const result = await getActiveSession('user123'); - - expect(result).toBeNull(); - }); - - it('should return the active session when found', async () => { - const mockSessionData = { - user_id: 'user123', - employee_id: 'emp456', - status: 'active', - active_agent: 'test-agent', - turn_history: [], - workflow_state: {}, - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - ttl: { toDate: () => new Date(Date.now() + 3600000) }, - }; - - const mockDocSnapshot = { - id: 'session123', - data: () => mockSessionData, - }; - - mockCollection.mockReturnValue({ - where: () => ({ - where: () => ({ - where: () => ({ - limit: () => ({ - get: () => - Promise.resolve({ - empty: false, - docs: [mockDocSnapshot], - }), - }), - }), - }), - }), - }); - - const result = await getActiveSession('user123'); - - expect(result).toEqual( - expect.objectContaining({ - session_id: 'session123', - user_id: 'user123', - employee_id: 'emp456', - status: 'active', - active_agent: 'test-agent', - }), - ); - }); - - it('should return null when session TTL has expired', async () => { - mockCollection.mockReturnValue({ - where: () => ({ - where: () => ({ - where: () => ({ - limit: () => ({ - get: () => Promise.resolve({ empty: true, docs: [] }), - }), - }), - }), - }), - }); - - const result = await getActiveSession('user123'); - - expect(result).toBeNull(); - }); - }); - - describe('appendTurn', () => { - it('should append a turn to the session history', async () => { - const mockTransactionGet = vi.fn().mockResolvedValue({ - exists: true, - data: () => ({ turn_history: [] }), - }); - - mockRunTransaction.mockImplementation(async (fn) => { - await fn({ - get: mockTransactionGet, - update: vi.fn().mockResolvedValue({}), - }); - }); - - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({}), - }); - - const turn = { - role: 'user' as const, - content: 'Hello, world!', - timestamp: '2024-01-01T00:00:00.000Z', - intent_summary: 'greeting', - }; - - await appendTurn('session123', turn); - - expect(mockRunTransaction).toHaveBeenCalled(); - }); - - it('should throw error when session not found', async () => { - mockRunTransaction.mockImplementation(async (fn) => { - await fn({ - get: vi.fn().mockResolvedValue({ exists: false }), - update: vi.fn(), - }); - }); - - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({}), - }); - - await expect( - appendTurn('nonexistent', { - role: 'user', - content: 'test', - timestamp: '2024-01-01T00:00:00.000Z', - }), - ).rejects.toThrow('Session nonexistent not found'); - }); - - it('should truncate turn history to max size', async () => { - const largeHistory = Array.from({ length: 150 }, (_, i) => ({ - role: 'user' as const, - content: `Turn ${i}`, - timestamp: '2024-01-01T00:00:00.000Z', - })); - - mockRunTransaction.mockImplementation(async (fn) => { - await fn({ - get: vi.fn().mockResolvedValue({ - exists: true, - data: () => ({ turn_history: largeHistory }), - }), - update: vi.fn().mockResolvedValue({}), - }); - }); - - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({}), - }); - - await appendTurn('session123', { - role: 'user', - content: 'New turn', - timestamp: '2024-01-01T00:00:00.000Z', - }); - - expect(mockRunTransaction).toHaveBeenCalled(); - }); - }); - - describe('updateWorkflowState', () => { - it('should update the workflow state', async () => { - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({ - update: mockUpdate.mockResolvedValue({}), - }), - }); - - const workflowState = { - step: 'verification', - verified: true, - data: { key: 'value' }, - }; - - await updateWorkflowState('session123', workflowState); - - expect(mockUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - workflow_state: workflowState, - updated_at: expect.any(String), - }), - ); - }); - }); - - describe('closeSession', () => { - it('should close a session with completed status', async () => { - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({ - update: mockUpdate.mockResolvedValue({}), - }), - }); - - await closeSession('session123', 'completed'); - - expect(mockUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'completed', - updated_at: expect.any(String), - }), - ); - expect(mockPublishMessage).toHaveBeenCalled(); - }); - - it('should close a session with abandoned status', async () => { - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({ - update: mockUpdate.mockResolvedValue({}), - }), - }); - - await closeSession('session123', 'abandoned'); - - expect(mockUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'abandoned', - }), - ); - }); - }); - - describe('resumeSession', () => { - it('should create a new session with prior history', async () => { - const priorData = { - user_id: 'user123', - employee_id: 'emp456', - status: 'completed', - active_agent: 'test-agent', - turn_history: [{ role: 'user', content: 'Hello', timestamp: '2024-01-01T00:00:00.000Z' }], - workflow_state: { step: 'in_progress' }, - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - ttl: { toDate: () => new Date(Date.now() + 3600000) }, - }; - - // Mock for getting prior session (first call) and creating new session (second call) - mockCollection.mockReturnValue({ - doc: mockDoc.mockImplementation((docId?: string) => { - if (docId === 'prior-session-123') { - return { - get: vi.fn().mockResolvedValue({ - exists: true, - id: 'prior-session-123', - data: () => priorData, - }), - update: mockUpdate.mockResolvedValue({}), - }; - } - - return { - get: vi.fn().mockResolvedValue({ - exists: true, - id: docId, - data: () => ({ - ...priorData, - status: 'active', - }), - }), - update: mockUpdate.mockResolvedValue({}), - create: mockCreate.mockResolvedValue({}), - }; - }), - }); - - const result = await resumeSession('prior-session-123'); - - expect(result).toBeDefined(); - expect(result?.session_id).not.toBe('prior-session-123'); - expect(result?.user_id).toBe('user123'); - }); - - it('should return null when prior session not found', async () => { - mockCollection.mockReturnValue({ - doc: mockDoc.mockReturnValue({ - get: vi.fn().mockResolvedValue({ exists: false }), - }), - }); - - const result = await resumeSession('nonexistent'); - - expect(result).toBeNull(); - }); - }); -}); diff --git a/tests/unit/sighup.test.ts b/tests/unit/sighup.test.ts deleted file mode 100644 index 3c0978a..0000000 --- a/tests/unit/sighup.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -const mockReloadRegistry = vi.fn().mockResolvedValue({ - success: true, - agentCount: 3, - agentIds: ['a', 'b', 'c'], - defaultAgentId: 'a', - errors: [], - warnings: [], -}); - -vi.mock('../../src/registry/registry.loader.js', () => ({ - reloadRegistry: mockReloadRegistry, -})); - -vi.mock('../../src/observability/logger.js', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('../../src/config/env.js', () => ({ - env: { - LOG_LEVEL: 'info', - }, -})); - -const { setupSighupHandler, triggerReload, isReloadPending, cleanupSighupHandler } = - await import('../../src/registry/sighup.js'); - -describe('SIGHUP Handler', () => { - beforeEach(() => { - cleanupSighupHandler(); - vi.clearAllMocks(); - mockReloadRegistry.mockResolvedValue({ - success: true, - agentCount: 3, - agentIds: ['a', 'b', 'c'], - defaultAgentId: 'a', - errors: [], - warnings: [], - }); - }); - - afterEach(() => { - cleanupSighupHandler(); - }); - - describe('setupSighupHandler', () => { - it('sets up handler without throwing', () => { - expect(() => setupSighupHandler(100)).not.toThrow(); - }); - - it('sets up handler with default debounce', () => { - expect(() => setupSighupHandler()).not.toThrow(); - }); - - it('registers SIGHUP event listener', () => { - setupSighupHandler(100); - const listeners = process.listeners('SIGHUP'); - expect(listeners.length).toBeGreaterThan(0); - }); - }); - - describe('triggerReload', () => { - it('calls reloadRegistry immediately', async () => { - await triggerReload(); - expect(mockReloadRegistry).toHaveBeenCalled(); - }); - - it('handles failed reload gracefully without throwing', async () => { - mockReloadRegistry.mockResolvedValueOnce({ - success: false, - agentCount: 0, - agentIds: [], - defaultAgentId: null, - errors: ['Load failed'], - warnings: [], - }); - - await expect(triggerReload()).resolves.toBeUndefined(); - }); - - it('clears any pending debounce timer', async () => { - setupSighupHandler(10000); - await triggerReload(); - expect(mockReloadRegistry).toHaveBeenCalled(); - }); - }); - - describe('isReloadPending', () => { - it('returns false initially', () => { - expect(isReloadPending()).toBe(false); - }); - - it('returns true after SIGHUP is received', async () => { - setupSighupHandler(100); - process.emit('SIGHUP'); - expect(isReloadPending()).toBe(true); - }); - }); - - describe('cleanupSighupHandler', () => { - it('does not throw when no handler is set', () => { - expect(() => cleanupSighupHandler()).not.toThrow(); - }); - - it('clears debounce timer', () => { - setupSighupHandler(10000); - cleanupSighupHandler(); - expect(() => cleanupSighupHandler()).not.toThrow(); - }); - - it('handles multiple cleanupSighupHandler calls', () => { - cleanupSighupHandler(); - cleanupSighupHandler(); - expect(() => cleanupSighupHandler()).not.toThrow(); - }); - }); - - describe('debounce behavior', () => { - it('coalesces multiple SIGHUP signals', async () => { - vi.useFakeTimers(); - - setupSighupHandler(1000); - - process.emit('SIGHUP'); - process.emit('SIGHUP'); - process.emit('SIGHUP'); - - expect(isReloadPending()).toBe(true); - - mockReloadRegistry.mockResolvedValueOnce({ - success: true, - agentCount: 3, - agentIds: ['a', 'b', 'c'], - defaultAgentId: 'a', - errors: [], - warnings: [], - }); - - await vi.advanceTimersByTimeAsync(1001); - - expect(mockReloadRegistry).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); - }); - - it('reload happens after debounce window', async () => { - vi.useFakeTimers(); - - setupSighupHandler(500); - - process.emit('SIGHUP'); - - await vi.advanceTimersByTimeAsync(501); - - expect(mockReloadRegistry).toHaveBeenCalled(); - - vi.useRealTimers(); - }); - }); -}); diff --git a/tests/unit/slackProfile.resolver.test.ts b/tests/unit/slackProfile.resolver.test.ts deleted file mode 100644 index 72628e6..0000000 --- a/tests/unit/slackProfile.resolver.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - SLACK_BOT_TOKEN: 'xoxb-test-token', - }, -})); - -const { - resolveSlackProfile, - resolveSlackProfileNoCache, - clearProfileCache, - preloadProfiles, - EmployeeNotFoundError, -} = await import('../../src/gateway/slackProfile.resolver.js'); - -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -function slackResponse(profile: Record = {}, ok = true, error?: string) { - return { - ok, - profile: { - display_name: 'Test User', - real_name: 'Test Real', - email: 'test@example.com', - title: 'Engineering', - ...profile, - }, - ...(error ? { error } : {}), - }; -} - -describe('resolveSlackProfile', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearProfileCache(); - }); - - afterEach(() => { - clearProfileCache(); - }); - - it('resolves profile from Slack API', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse()), - }); - - const profile = await resolveSlackProfile('U123'); - expect(profile.employee_id).toBe('U123'); - expect(profile.display_name).toBe('Test User'); - expect(profile.email).toBe('test@example.com'); - }); - - it('caches the profile', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse()), - }); - - const p1 = await resolveSlackProfile('U123'); - const p2 = await resolveSlackProfile('U123'); - expect(p1).toEqual(p2); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('uses cached profile within TTL', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ display_name: 'Fresh' })), - }); - - await resolveSlackProfile('U_CACHE'); - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ display_name: 'Stale' })), - }); - - const profile = await resolveSlackProfile('U_CACHE'); - expect(profile.display_name).toBe('Fresh'); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('throws EmployeeNotFoundError for user_not_found', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ ok: false, error: 'user_not_found' }), - }); - - await expect(resolveSlackProfile('U_MISSING')).rejects.toThrow(EmployeeNotFoundError); - }); - - it('throws EmployeeNotFoundError for user_not_found via error field', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ ok: false, error: 'user_not_found' }), - }); - - await expect(resolveSlackProfile('U_MISSING2')).rejects.toThrow(EmployeeNotFoundError); - }); - - it('returns fallback profile on unknown Slack API error', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ ok: false, error: 'unknown_error' }), - }); - - const profile = await resolveSlackProfile('U_ERR'); - expect(profile.employee_id).toBe('U_ERR'); - expect(profile.display_name).toBe('U_ERR'); - expect(profile.email).toBe('U_ERR@company.com'); - }); - - it('returns fallback profile on Slack API error response', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ ok: false, error: 'not_authed' }), - }); - - const profile = await resolveSlackProfile('U123'); - expect(profile.employee_id).toBe('U123'); - expect(profile.display_name).toBe('U123'); - }); - - it('throws on HTTP error', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); - - await expect(resolveSlackProfile('U123')).resolves.toEqual( - expect.objectContaining({ - employee_id: 'U123', - display_name: 'U123', - }), - ); - }); - - it('returns fallback profile on network error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - const profile = await resolveSlackProfile('U_NETERR'); - expect(profile.employee_id).toBe('U_NETERR'); - expect(profile.display_name).toBe('U_NETERR'); - expect(profile.email).toContain('@company.com'); - }); - - it('extracts title from profile', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ title: 'Software Engineer' })), - }); - - const profile = await resolveSlackProfile('U_TITLE'); - expect(profile.title).toBe('Software Engineer'); - }); - - it('extracts department from title', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ title: 'Engineering - Backend' })), - }); - - const profile = await resolveSlackProfile('U_DEPT'); - expect(profile.department).toBe('Engineering'); - }); - - it('uses user_id as employee_id fallback', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({})), - }); - - const profile = await resolveSlackProfile('U_FALLBACK'); - expect(profile.employee_id).toBe('U_FALLBACK'); - }); -}); - -describe('resolveSlackProfileNoCache', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearProfileCache(); - }); - - afterEach(() => { - clearProfileCache(); - }); - - it('bypasses cache and fetches fresh', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ display_name: 'Fresh' })), - }); - - await resolveSlackProfile('U_FRESH'); - - const profile = await resolveSlackProfileNoCache('U_FRESH'); - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(profile.display_name).toBe('Fresh'); - }); - - it('removes existing cache entry', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ display_name: 'First' })), - }); - - await resolveSlackProfile('U_NOCACHE'); - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ display_name: 'Second' })), - }); - - const profile = await resolveSlackProfileNoCache('U_NOCACHE'); - expect(profile.display_name).toBe('Second'); - }); -}); - -describe('preloadProfiles', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearProfileCache(); - }); - - afterEach(() => { - clearProfileCache(); - }); - - it('preloads profiles for multiple users', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse()), - }); - - const results = await preloadProfiles(['U1', 'U2', 'U3']); - expect(results.size).toBe(3); - expect(results.has('U1')).toBe(true); - expect(results.has('U2')).toBe(true); - expect(results.has('U3')).toBe(true); - }); - - it('handles partial failures with fallback profiles', async () => { - mockFetch.mockImplementation((url: string) => { - if (url.includes('U_BAD')) { - return Promise.reject(new Error('fail')); - } - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(slackResponse()), - }); - }); - - const results = await preloadProfiles(['U_OK', 'U_BAD']); - expect(results.has('U_OK')).toBe(true); - expect(results.has('U_BAD')).toBe(true); - const badProfile = results.get('U_BAD'); - expect(badProfile?.employee_id).toBe('U_BAD'); - }); - - it('returns empty map for empty input', async () => { - const results = await preloadProfiles([]); - expect(results.size).toBe(0); - }); -}); - -describe('EmployeeNotFoundError', () => { - it('has correct name and message', () => { - const error = new EmployeeNotFoundError('Test message'); - expect(error.name).toBe('EmployeeNotFoundError'); - expect(error.message).toBe('Test message'); - }); - - it('has default message', () => { - const error = new EmployeeNotFoundError(); - expect(error.message).toBe('Employee not found'); - }); -}); - -describe('clearProfileCache', () => { - it('clears the cache', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse()), - }); - - await resolveSlackProfile('U_CACHE1'); - clearProfileCache(); - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(slackResponse({ display_name: 'New' })), - }); - - const profile = await resolveSlackProfile('U_CACHE1'); - expect(profile.display_name).toBe('New'); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); -}); diff --git a/tests/unit/tls.middleware.test.ts b/tests/unit/tls.middleware.test.ts deleted file mode 100644 index a2e5b4b..0000000 --- a/tests/unit/tls.middleware.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { Request, Response, NextFunction } from 'express'; - -vi.mock('../../src/config/env.js', () => ({ - env: { - NODE_ENV: 'development', - }, -})); - -const { tlsMiddleware, httpsRedirectMiddleware, hstsMiddleware, securityHeadersMiddleware } = - await import('../../src/gateway/tls.middleware.js'); - -function mockReqResNext(overrides: Partial = {}) { - const req = { - secure: false, - headers: {}, - path: '/v1/request', - originalUrl: '/v1/request', - get: vi.fn((name: string) => { - if (name === 'Host') { - return 'example.com'; - } - return undefined; - }), - method: 'POST', - ...overrides, - } as unknown as Request; - - const res = { - redirect: vi.fn(), - set: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - } as unknown as Response; - - const next = vi.fn() as NextFunction; - - return { req, res, next }; -} - -describe('tlsMiddleware', () => { - it('calls next and sets security headers in development mode', () => { - const { req, res, next } = mockReqResNext(); - tlsMiddleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(res.set).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); - expect(res.set).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); - expect(res.set).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block'); - expect(res.set).toHaveBeenCalledWith('Referrer-Policy', 'strict-origin-when-cross-origin'); - }); -}); - -describe('httpsRedirectMiddleware', () => { - it('calls next in development mode (no redirect)', () => { - const { req, res, next } = mockReqResNext(); - httpsRedirectMiddleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(res.redirect).not.toHaveBeenCalled(); - }); -}); - -describe('securityHeadersMiddleware', () => { - it('sets all security headers and calls next', () => { - const { req, res, next } = mockReqResNext(); - securityHeadersMiddleware(req, res, next); - expect(res.set).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); - expect(res.set).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); - expect(res.set).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block'); - expect(res.set).toHaveBeenCalledWith( - 'Content-Security-Policy', - "default-src 'none'; frame-ancestors 'none'", - ); - expect(res.set).toHaveBeenCalledWith('Referrer-Policy', 'strict-origin-when-cross-origin'); - expect(res.set).toHaveBeenCalledWith( - 'Permissions-Policy', - 'camera=(), microphone=(), geolocation=(), payment=()', - ); - expect(next).toHaveBeenCalled(); - }); -}); - -describe('hstsMiddleware', () => { - it('does not set HSTS in development', () => { - const { req, res, next } = mockReqResNext(); - hstsMiddleware(req, res, next); - expect(res.set).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 2ef900c..6352f46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,10 @@ { "compilerOptions": { + "ignoreDeprecations": "6.0", "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -14,7 +13,6 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "removeComments": false, "noImplicitAny": true, "noImplicitReturns": true, "noImplicitThis": true, @@ -22,16 +20,10 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, "allowUnreachableCode": false, "allowUnusedLabels": false, "isolatedModules": true, "types": ["node"] }, - "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist", "coverage"], - "ts-node": { - "esm": true, - "experimentalSpecifierResolution": "node" - } + "exclude": ["node_modules", "dist", "coverage"] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..715ed3a --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@reaatech/agent-mesh": ["./packages/core/src/index.ts"], + "@reaatech/agent-mesh-observability": ["./packages/observability/src/index.ts"], + "@reaatech/agent-mesh-utils": ["./packages/utils/src/index.ts"], + "@reaatech/agent-mesh-registry": ["./packages/registry/src/index.ts"], + "@reaatech/agent-mesh-session": ["./packages/session/src/index.ts"], + "@reaatech/agent-mesh-classifier": ["./packages/classifier/src/index.ts"], + "@reaatech/agent-mesh-confidence": ["./packages/confidence/src/index.ts"], + "@reaatech/agent-mesh-router": ["./packages/router/src/index.ts"], + "@reaatech/agent-mesh-gateway": ["./packages/gateway/src/index.ts"], + "@reaatech/agent-mesh-mcp-server": ["./packages/mcp-server/src/index.ts"] + } + }, + "include": ["packages/*/src/**/*", "examples/*/src/**/*", "e2e/src/**/*"] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..cde0a2a --- /dev/null +++ b/turbo.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "test": { + "dependsOn": ["build"] + }, + "test:coverage": { + "dependsOn": ["build"] + }, + "lint": {}, + "typecheck": { + "dependsOn": ["^build"] + }, + "clean": { + "cache": false + } + } +}