diff --git a/docs/adding-engines.md b/docs/adding-engines.md new file mode 100644 index 00000000..170cfeca --- /dev/null +++ b/docs/adding-engines.md @@ -0,0 +1,549 @@ +# Adding a New Agent Engine + +This guide walks through every step required to add a new agent engine to CASCADE — from choosing the right archetype to running your first test. Following this guide, adding a new subprocess-based engine (e.g., Gemini CLI, Kilo Code, Continue.dev) should take a few hours, not days. + +--- + +## 1. Choose an Archetype: `native-tool` vs `sdk` + +CASCADE supports two engine archetypes. Choose the one that matches how the tool runs. + +### `native-tool` — Subprocess-based CLI tools + +Use this when your engine: +- Runs as an **external CLI** process (spawned via `child_process.spawn`) +- Has its own built-in file/bash tools (Read, Write, Edit, Bash, Glob, Grep) +- Communicates by receiving a prompt via stdin/HTTP and streaming output +- Examples: **Claude Code**, **Codex**, **OpenCode** + +The `native-tool` archetype provides the `NativeToolEngine` base class (`src/backends/shared/NativeToolEngine.ts`), which handles: +- Shared subprocess environment construction via `buildEngineEnv()` +- `resolveModel()` delegation to your `resolveEngineModel()` implementation +- `supportsAgentType()` returning `true` for all agent types (override if needed) +- `afterExecute()` cleanup for offloaded context files + +### `sdk` — In-process SDK integrations + +Use this when your engine: +- Runs **in-process** as a TypeScript/JavaScript SDK (no subprocess) +- Manages its own LLM API calls directly +- Injects context via synthetic tool calls rather than subprocess environment variables +- Example: **LLMist** (`src/backends/llmist/`) + +For `sdk` engines, implement the `AgentEngine` interface directly (see `src/backends/types.ts`). There is no base class — you are responsible for all lifecycle details. Use the LLMist engine as your reference implementation. + +**When in doubt, use `native-tool`.** It is the more common pattern and has more shared infrastructure. + +--- + +## 2. Create the Engine Directory + +Create a new directory under `src/backends//`. The standard layout for a native-tool engine: + +``` +src/backends/my-engine/ +├── index.ts # Main engine class (extends NativeToolEngine) +├── env.ts # Env-var allowlist (exports ALLOWED_ENV_EXACT) +├── models.ts # Model ID list and default +└── settings.ts # Zod schema + resolver for engine-specific settings (optional) +``` + +--- + +## 3. Define the Engine in `catalog.ts` + +Add an `AgentEngineDefinition` constant to `src/backends/catalog.ts`: + +```typescript +// src/backends/catalog.ts +export const MY_ENGINE_DEFINITION: AgentEngineDefinition = { + id: 'my-engine', // Stable string ID — used in DB and config + label: 'My Engine', // Human-readable label for the dashboard + description: 'Short description of what this engine does.', + archetype: 'native-tool', // or 'sdk' for in-process engines + capabilities: [ + 'inline_prompt_context', + 'offloaded_context_files', + 'native_file_edit_tools', + 'external_cli_tools', + 'streaming_text_events', + 'streaming_tool_events', + 'scoped_env_secrets', + ], + modelSelection: { + type: 'select', // or 'free-text' for open-ended model strings + defaultValueLabel: 'Default (v1.0)', + options: MY_ENGINE_MODELS, // Imported from ./my-engine/models.ts + }, + logLabel: 'My Engine Log', + // Optional: add 'settings' if your engine has configurable fields +}; +``` + +Add it to `DEFAULT_ENGINE_CATALOG` at the bottom of the same file: + +```typescript +export const DEFAULT_ENGINE_CATALOG: AgentEngineDefinition[] = [ + CLAUDE_CODE_ENGINE_DEFINITION, + LLMIST_ENGINE_DEFINITION, + CODEX_ENGINE_DEFINITION, + OPENCODE_ENGINE_DEFINITION, + MY_ENGINE_DEFINITION, // ← add here +]; +``` + +--- + +## 4. Add Env Filtering (`env.ts`) + +Every native-tool engine needs an **allowlist** of environment variables that may be passed to its subprocess. This prevents server-side secrets (`DATABASE_URL`, `REDIS_URL`, `CREDENTIAL_MASTER_KEY`, etc.) from leaking into agent processes. + +Create `src/backends/my-engine/env.ts`: + +```typescript +// src/backends/my-engine/env.ts +import { SHARED_ALLOWED_ENV_EXACT } from '../shared/envFilter.js'; + +/** + * Exact variable names to pass through (shared + My Engine-specific). + * Extend the shared set with auth vars specific to your engine. + */ +export const ALLOWED_ENV_EXACT = new Set([ + ...SHARED_ALLOWED_ENV_EXACT, + + // My Engine auth + 'MY_ENGINE_API_KEY', + + // Squint (pass through so agents can use AST tooling) + 'SQUINT_DB_PATH', +]); +``` + +The shared set (`SHARED_ALLOWED_ENV_EXACT` from `src/backends/shared/envFilter.js`) already includes: +- System vars: `HOME`, `PATH`, `SHELL`, `USER`, `LANG`, `TZ` +- Node vars: `NODE_PATH`, `NODE_EXTRA_CA_CERTS` +- Editor/color: `EDITOR`, `FORCE_COLOR`, `NO_COLOR` +- CASCADE internal: progress comment state, GitHub ack comment ID, session state vars + +The shared prefix allowlist (`SHARED_ALLOWED_ENV_PREFIXES`) passes through `LC_*`, `XDG_*`, `GIT_*`, `SSH_*`, `GPG_*`, and `DOCKER_*` automatically. + +--- + +## 5. Define Models (`models.ts`) + +Create `src/backends/my-engine/models.ts`: + +```typescript +// src/backends/my-engine/models.ts +export const MY_ENGINE_MODELS = [ + { value: 'my-engine-v1', label: 'My Engine v1 (default)' }, + { value: 'my-engine-v2', label: 'My Engine v2 (latest)' }, +] as const; + +export const MY_ENGINE_MODEL_IDS = MY_ENGINE_MODELS.map((m) => m.value); +export const DEFAULT_MY_ENGINE_MODEL = 'my-engine-v1'; +``` + +If your engine accepts arbitrary model strings (e.g., OpenCode which uses `provider/model` format), use `modelSelection: { type: 'free-text' }` in the definition and skip the model list. + +--- + +## 6. Add Settings Schema (Optional) + +If your engine has configurable behaviour (e.g., approval policy, reasoning effort, web search), define a Zod schema for it. + +Create `src/backends/my-engine/settings.ts`: + +```typescript +// src/backends/my-engine/settings.ts +import { z } from 'zod'; +import { type EngineSettings, getEngineSettings } from '../../config/engineSettings.js'; +import type { ProjectConfig } from '../../types/index.js'; + +export const MY_ENGINE_SETTING_DEFAULTS = { + mode: 'balanced' as const, + webSearch: false, +}; + +export const MyEngineSettingsSchema = z.object({ + mode: z.enum(['fast', 'balanced', 'thorough']).optional(), + webSearch: z.boolean().optional(), +}); + +export type MyEngineSettings = z.infer; + +export function resolveMyEngineSettings( + project: ProjectConfig, + engineSettings?: EngineSettings, +): Required { + const effectiveSettings = engineSettings ?? project.engineSettings; + const settings = getEngineSettings(effectiveSettings, 'my-engine', MyEngineSettingsSchema) ?? {}; + return { + mode: settings.mode ?? MY_ENGINE_SETTING_DEFAULTS.mode, + webSearch: settings.webSearch ?? MY_ENGINE_SETTING_DEFAULTS.webSearch, + }; +} +``` + +Then expose these settings in your `AgentEngineDefinition` (in `catalog.ts`): + +```typescript +settings: { + title: 'My Engine Settings', + description: 'Behaviour controls for My Engine runs.', + fields: [ + { + key: 'mode', + label: 'Mode', + type: 'select', + options: [ + { value: 'fast', label: 'Fast' }, + { value: 'balanced', label: 'Balanced' }, + { value: 'thorough', label: 'Thorough' }, + ], + }, + { + key: 'webSearch', + label: 'Web Search', + type: 'boolean', + description: 'Allow web search during runs.', + }, + ], +}, +``` + +--- + +## 7. Implement the Engine Class (`index.ts`) + +Here is the minimal template for a native-tool subprocess engine. This is what you **must** implement: + +```typescript +// src/backends/my-engine/index.ts +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +import { MY_ENGINE_DEFINITION } from '../catalog.js'; +import { NativeToolEngine } from '../shared/NativeToolEngine.js'; +import { buildEngineResult, extractAndBuildPrEvidence } from '../shared/engineResult.js'; +import { buildSystemPrompt, buildTaskPrompt } from '../shared/nativeToolPrompts.js'; +import type { AgentEngineResult, AgentExecutionPlan } from '../types.js'; +import { ALLOWED_ENV_EXACT } from './env.js'; +import { DEFAULT_MY_ENGINE_MODEL, MY_ENGINE_MODEL_IDS } from './models.js'; +import { MyEngineSettingsSchema, resolveMyEngineSettings } from './settings.js'; + +// ─── Model resolution ──────────────────────────────────────────────────────── + +function resolveMyEngineModel(cascadeModel: string): string { + if (MY_ENGINE_MODEL_IDS.includes(cascadeModel)) return cascadeModel; + // Add engine-prefixed model strings if your engine supports them + throw new Error( + `Model "${cascadeModel}" is not compatible with My Engine. ` + + `Configure a supported model (e.g. "${DEFAULT_MY_ENGINE_MODEL}") or switch engines.` + ); +} + +// ─── Engine class ──────────────────────────────────────────────────────────── + +/** + * My Engine backend for CASCADE. + * + * Extends NativeToolEngine to share subprocess env-building, supportsAgentType(), + * resolveModel() delegation, and base afterExecute() context cleanup. + */ +export class MyEngine extends NativeToolEngine { + readonly definition = MY_ENGINE_DEFINITION; + + // ── NativeToolEngine abstract methods ────────────────────────────────────── + + getAllowedEnvExact(): Set { + return ALLOWED_ENV_EXACT; + } + + getExtraEnvVars(): Record { + return { CI: 'true' }; + } + + resolveEngineModel(cascadeModel: string): string { + return resolveMyEngineModel(cascadeModel); + } + + // ── Optional lifecycle hooks ─────────────────────────────────────────────── + + getSettingsSchema() { + return MyEngineSettingsSchema; + } + + async beforeExecute(plan: AgentExecutionPlan): Promise { + // Write auth files, validate prerequisites, etc. + // Called by the adapter before execute(). + } + + async afterExecute(plan: AgentExecutionPlan, result: AgentEngineResult): Promise { + await super.afterExecute(plan, result); // Cleans up offloaded context files + // Additional cleanup (remove temp files, kill sidecars, etc.) + } + + // ── Core execution ───────────────────────────────────────────────────────── + + async execute(input: AgentExecutionPlan): Promise { + const startTime = Date.now(); + + // 1. Build prompts + const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools); + const { prompt: taskPrompt, hasOffloadedContext: _hasOffloadedContext } = await buildTaskPrompt( + input.taskPrompt, + input.contextInjections, + input.repoDir, + ); + + // 2. Resolve model — idempotent, safe to call even without the adapter + const model = resolveMyEngineModel(input.model); + + // 3. Resolve settings + const settings = resolveMyEngineSettings(input.project, input.engineSettings); + + // 4. Build subprocess environment + const env = this.buildEnv(input.projectSecrets, input.cliToolsDir, input.nativeToolShimDir); + + input.logWriter('INFO', 'Starting My Engine execution', { + agentType: input.agentType, + model, + repoDir: input.repoDir, + maxIterations: input.maxIterations, + }); + + // 5. Spawn the subprocess and stream output + const rawTextParts: string[] = []; + const stderrChunks: string[] = []; + let iterationCount = 0; + + const exitCode = await new Promise((resolve, reject) => { + const child = spawn('my-engine', [ + '--model', model, + '--mode', settings.mode, + '--json', // Request JSONL/structured output if available + input.repoDir, + ], { + cwd: input.repoDir, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.once('error', (error) => { + reject( + error instanceof Error && 'code' in error && error.code === 'ENOENT' + ? new Error('my-engine CLI not found in PATH. Install it in the worker image.') + : error, + ); + }); + + // Pipe the combined prompt to stdin + child.stdin.write(`${systemPrompt}\n\n${taskPrompt}`); + child.stdin.end(); + + const stdout = createInterface({ input: child.stdout }); + stdout.on('line', (line) => { + rawTextParts.push(line); + input.progressReporter.onText(line); + iterationCount++; + void input.progressReporter.onIteration(iterationCount, input.maxIterations); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + stderrChunks.push(chunk.toString()); + }); + + child.once('close', (code) => resolve(code ?? 1)); + }); + + const finalOutput = rawTextParts.join('\n').trim(); + const { prUrl, prEvidence } = extractAndBuildPrEvidence(finalOutput); + + input.logWriter('INFO', 'My Engine execution completed', { + exitCode, + turns: iterationCount, + durationMs: Date.now() - startTime, + }); + + if (exitCode !== 0) { + return buildEngineResult({ + success: false, + output: finalOutput, + error: stderrChunks.join('').trim() || `my-engine exited with code ${exitCode}`, + prUrl, + prEvidence, + }); + } + + return buildEngineResult({ + success: true, + output: finalOutput, + prUrl, + prEvidence, + }); + } +} + +export { resolveMyEngineModel }; +``` + +### Key helpers used above + +| Helper | Location | Purpose | +|--------|----------|---------| +| `buildSystemPrompt(systemPrompt, availableTools)` | `src/backends/shared/nativeToolPrompts.ts` | Formats the system prompt with tool guidance | +| `buildTaskPrompt(taskPrompt, contextInjections, repoDir)` | `src/backends/shared/nativeToolPrompts.ts` | Offloads large context to files when needed | +| `this.buildEnv(projectSecrets, cliToolsDir, nativeToolShimDir)` | `NativeToolEngine` base class | Builds a sanitised subprocess env | +| `buildEngineResult({ ... })` | `src/backends/shared/engineResult.ts` | Constructs `AgentEngineResult` | +| `extractAndBuildPrEvidence(output)` | `src/backends/shared/engineResult.ts` | Extracts PR URL from output text | + +--- + +## 8. Register in `bootstrap.ts` + +Add your engine to `src/backends/bootstrap.ts` so it is available at runtime: + +```typescript +// src/backends/bootstrap.ts +import { MyEngine } from './my-engine/index.js'; + +export function registerBuiltInEngines(): void { + // ... existing engines ... + if (!getEngine('my-engine')) { + registerEngineWithSettings(new MyEngine()); + } +} +``` + +`registerEngineWithSettings` handles both `registerEngine()` and `registerEngineSettingsSchema()` in one call. If your engine does not implement `getSettingsSchema()`, use `registerEngine()` directly instead. + +--- + +## 9. Update `Dockerfile.worker` + +Install your engine's CLI binary in `Dockerfile.worker`. Follow the pattern used for the existing engines: + +```dockerfile +# Install my-engine CLI +RUN npm install -g @my-org/my-engine@1.0.0 +``` + +Search `Dockerfile.worker` for `@anthropic-ai/claude-code` or `@openai/codex` to find the right place to add your install step. + +--- + +## 10. Test Your Engine + +### A. Engine-contract test (required) + +Create `tests/unit/backends/my-engine.test.ts` to verify the contract. Look at existing engine tests for the pattern: + +``` +tests/unit/backends/ +├── claude-code.test.ts +├── codex.test.ts +└── opencode.test.ts +``` + +At minimum, test: +1. **`definition`** — correct `id`, `archetype`, `capabilities` +2. **`resolveEngineModel()`** — valid model strings pass, invalid ones throw +3. **`getAllowedEnvExact()`** — contains engine auth var; does NOT contain blocked vars +4. **`getExtraEnvVars()`** — returns expected constants (e.g. `CI: 'true'`) +5. **`getSettingsSchema()`** — schema validates expected shape (if implemented) +6. **`execute()`** — with mocked subprocess, returns expected result shape + +### B. Env filter test (required) + +Create `tests/unit/backends/my-engine-env.test.ts` to verify that sensitive variables are blocked: + +```typescript +import { ALLOWED_ENV_EXACT } from '../../../src/backends/my-engine/env.js'; +import { SHARED_BLOCKED_ENV_EXACT } from '../../../src/backends/shared/envFilter.js'; + +it('does not allow any blocked vars', () => { + for (const blocked of SHARED_BLOCKED_ENV_EXACT) { + expect(ALLOWED_ENV_EXACT.has(blocked)).toBe(false); + } +}); + +it('allows engine auth var', () => { + expect(ALLOWED_ENV_EXACT.has('MY_ENGINE_API_KEY')).toBe(true); +}); +``` + +### C. Unit tests for settings (if applicable) + +Test that `resolveMyEngineSettings()` applies defaults correctly and that the schema validates the expected shape. + +### Running tests + +```bash +npm test # All unit tests +npx vitest run tests/unit/backends/ # Just backend tests +``` + +--- + +## 11. Wire Up CLI and Dashboard (Automatic) + +No CLI or dashboard changes are needed. CASCADE reads the engine catalog dynamically: + +- **Dashboard Project Settings** — `getEngineCatalog()` returns all registered engines +- **`cascade projects update --agent-engine my-engine`** — the CLI uses the same dynamic list +- **`cascade agents create --engine my-engine`** — same +- Engine settings fields defined in `AgentEngineDefinition.settings` are rendered automatically in the Agent Configs tab + +--- + +## Summary Checklist + +- [ ] Create `src/backends/my-engine/` directory with `index.ts`, `env.ts`, `models.ts` +- [ ] Add `MY_ENGINE_DEFINITION` to `src/backends/catalog.ts` with correct `archetype` +- [ ] Set `ALLOWED_ENV_EXACT` in `env.ts` — extends shared set, adds engine auth vars +- [ ] Implement `getSettingsSchema()` and `settings.ts` if the engine has configurable options +- [ ] Implement `resolveEngineModel()` — validate and map CASCADE model strings +- [ ] Implement `execute()` — spawn subprocess, stream output, return `AgentEngineResult` +- [ ] Implement `beforeExecute()` / `afterExecute()` hooks if auth files or cleanup are needed +- [ ] Register in `src/backends/bootstrap.ts` via `registerEngineWithSettings()` +- [ ] Add engine CLI install step to `Dockerfile.worker` +- [ ] Write engine-contract tests and env-filter tests +- [ ] Run `npm test` and `npm run typecheck` — all green + +--- + +## Real-World Examples + +Refer to these implementations for patterns and guidance: + +| Engine | Archetype | Location | Notable patterns | +|--------|-----------|----------|-----------------| +| Claude Code | `native-tool` | `src/backends/claude-code/` | SDK-based (not subprocess), `beforeExecute` writes onboarding flag, `afterExecute` cleans up session | +| Codex | `native-tool` | `src/backends/codex/` | Subprocess via `spawn`, JSONL output parsing, subscription auth with token refresh | +| OpenCode | `native-tool` | `src/backends/opencode/` | HTTP server protocol, `runContinuationLoop` for multi-turn, permission policy config | +| LLMist | `sdk` | `src/backends/llmist/` | In-process SDK, synthetic context injection, no `NativeToolEngine` base class | + +--- + +## Architecture Quick-Reference + +``` +src/backends/ +├── types.ts # AgentEngine, AgentEngineDefinition, AgentExecutionPlan interfaces +├── catalog.ts # AgentEngineDefinition constants + DEFAULT_ENGINE_CATALOG +├── registry.ts # Runtime engine registry (registerEngine, getEngine, isNativeToolEngine) +├── bootstrap.ts # Registers all built-in engines + their settings schemas +├── adapter.ts # Shared lifecycle: repo setup, prompts, secrets, post-processing +├── shared/ +│ ├── NativeToolEngine.ts # Abstract base class for native-tool engines +│ ├── envFilter.ts # Shared env-var allowlists and filterProcessEnv() +│ ├── envBuilder.ts # buildEngineEnv() — the single env construction entry point +│ ├── nativeToolPrompts.ts # buildSystemPrompt() and buildTaskPrompt() helpers +│ ├── engineResult.ts # buildEngineResult(), extractAndBuildPrEvidence() +│ └── contextFiles.ts # cleanupContextFiles() for offloaded context +├── claude-code/ # Native-tool (SDK-based) +├── codex/ # Native-tool (subprocess, JSONL) +├── opencode/ # Native-tool (HTTP server protocol) +└── llmist/ # SDK archetype (in-process) +``` diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 7d976a98..cdd3bdfb 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -398,4 +398,35 @@ export const integrationsDiscoveryRouter = router({ }); } }), + + /** + * Verify a Sentry API token and organization slug. + * Used by the Integrations tab Alerting credential inputs. + * Accepts plaintext credentials from the form and calls the Sentry API to verify. + * The token is never stored by this endpoint. + */ + verifySentry: protectedProcedure + .input(z.object({ apiToken: z.string().min(1), organizationSlug: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifySentry called', { orgId: ctx.effectiveOrgId }); + return wrapIntegrationCall('Failed to verify Sentry credentials', async () => { + const url = `https://sentry.io/api/0/organizations/${encodeURIComponent(input.organizationSlug)}/`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${input.apiToken}` }, + }); + if (!response.ok) { + throw new Error(`Sentry API returned ${response.status}: ${response.statusText}`); + } + const data = (await response.json()) as { + id?: string; + name?: string; + slug?: string; + }; + return { + id: data.id ?? '', + name: data.name ?? '', + slug: data.slug ?? '', + }; + }); + }), }); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 77f62db0..6a2f28f3 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -176,7 +176,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm']), + category: z.enum(['pm', 'scm', 'alerting']), provider: z.string().min(1), config: z.record(z.unknown()), triggers: z.record(z.boolean()).optional(), @@ -197,7 +197,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm']), + category: z.enum(['pm', 'scm', 'alerting']), triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])), }), ) @@ -207,7 +207,7 @@ export const projectsRouter = router({ }), delete: protectedProcedure - .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'alerting']) })) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); await deleteProjectIntegration(input.projectId, input.category); diff --git a/src/backends/README.md b/src/backends/README.md index 2b9dec73..86b3f637 100644 --- a/src/backends/README.md +++ b/src/backends/README.md @@ -2,18 +2,32 @@ CASCADE runs coding agents through a shared execution lifecycle and a pluggable engine registry. -Core pieces: +## Core pieces -- `types.ts`: canonical engine contracts -- `registry.ts`: runtime engine registry and catalog source -- `bootstrap.ts`: built-in engine registration +- `types.ts`: canonical engine contracts (`AgentEngine`, `AgentEngineDefinition`, `AgentExecutionPlan`) +- `catalog.ts`: static engine definitions with `archetype` field (`sdk` or `native-tool`) +- `registry.ts`: runtime engine registry (`registerEngine`, `getEngine`, `isNativeToolEngine`) +- `bootstrap.ts`: built-in engine registration (also registers settings schemas) - `adapter.ts`: shared lifecycle around repo setup, prompts, progress, secrets, run tracking, and post-processing -- `llmist/`, `claude-code/`, `codex/`, and `opencode/`: engine-specific adapters +- `shared/NativeToolEngine.ts`: abstract base class for subprocess-based engines (Claude Code, Codex, OpenCode) +- `llmist/`, `claude-code/`, `codex/`, `opencode/`: engine-specific implementations -To add a new engine: +## Archetypes -1. Implement `AgentEngine` with a stable `definition.id`. -2. Register it through the engine registry. -3. Keep orchestration concerns in the shared adapter unless they are truly engine-specific. +Every engine declares an `archetype` in its `AgentEngineDefinition`: -The rest of the product should consume engine metadata dynamically rather than branching on engine names. +- **`native-tool`** — subprocess-based CLI tools (Claude Code, Codex, OpenCode). Extend `NativeToolEngine` from `shared/NativeToolEngine.ts`. The base class provides shared env-building, `supportsAgentType()`, `resolveModel()` delegation, and context file cleanup. +- **`sdk`** — in-process SDK integrations (LLMist). Implement `AgentEngine` directly; no base class is used. + +## To add a new engine + +See [`docs/adding-engines.md`](../../docs/adding-engines.md) for the full step-by-step guide, including archetype selection, env filtering, settings schemas, model resolution, registration, and testing. + +At a high level: + +1. Choose archetype: extend `NativeToolEngine` for subprocess CLIs, implement `AgentEngine` directly for in-process SDKs. +2. Create `src/backends//` with `index.ts`, `env.ts`, `models.ts`, and optionally `settings.ts`. +3. Add an `AgentEngineDefinition` with the `archetype` field to `catalog.ts`. +4. Register the engine (and its settings schema) in `bootstrap.ts`. + +The rest of the product consumes engine metadata dynamically via `getEngineCatalog()` — no branching on engine names required. diff --git a/src/router/adapters/github.ts b/src/router/adapters/github.ts index 7b8f77c4..da717bd7 100644 --- a/src/router/adapters/github.ts +++ b/src/router/adapters/github.ts @@ -331,7 +331,8 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { // Build the GitHub PR ack message with run link included before posting, // so the actual comment on the PR contains the footer (not just internal metadata). let githubAckMessage: string | undefined; - if (runLinksEnabled && event.workItemId) { + const workItemIdForLink = triggerResult?.workItemId ?? event.workItemId; + if (runLinksEnabled && workItemIdForLink) { const dashboardUrl = getDashboardUrl(); if (dashboardUrl) { const context = extractGitHubContext(payload, event.eventType); @@ -339,7 +340,7 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { const link = buildWorkItemRunsLink({ dashboardUrl, projectId: project.id, - workItemId: event.workItemId, + workItemId: workItemIdForLink, }); githubAckMessage = link ? baseMessage + link : baseMessage; } diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 6c8b1d02..6eab94b8 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -19,6 +19,7 @@ const { mockVerifyProjectOrgAccess, mockGetIntegrationCredentialOrNull, mockGetIntegrationByProjectAndCategory, + mockFetch, } = vi.hoisted(() => ({ mockTrelloGetMe: vi.fn(), mockTrelloGetBoards: vi.fn(), @@ -36,6 +37,7 @@ const { mockVerifyProjectOrgAccess: vi.fn(), mockGetIntegrationCredentialOrNull: vi.fn(), mockGetIntegrationByProjectAndCategory: vi.fn(), + mockFetch: vi.fn(), })); vi.mock('../../../../src/trello/client.js', () => ({ @@ -106,10 +108,14 @@ const jiraCredsInput = { baseUrl: 'https://myorg.atlassian.net', }; +// Assign global fetch mock +vi.stubGlobal('fetch', mockFetch); + describe('integrationsDiscoveryRouter', () => { beforeEach(() => { // Default: org access check passes mockVerifyProjectOrgAccess.mockResolvedValue(undefined); + mockFetch.mockReset(); }); // ── Auth ───────────────────────────────────────────────────────────── @@ -953,4 +959,103 @@ describe('integrationsDiscoveryRouter', () => { await expect(caller.verifyGithubToken({ token: '' })).rejects.toThrow(); }); }); + + // ── verifySentry ───────────────────────────────────────────────────── + + describe('verifySentry', () => { + it('returns org id, name, and slug on success', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: 'org-123', name: 'My Org', slug: 'my-org' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + }); + + expect(result).toEqual({ id: 'org-123', name: 'My Org', slug: 'my-org' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/my-org/', + expect.objectContaining({ + headers: { Authorization: 'Bearer sntrys_abc' }, + }), + ); + }); + + it('returns empty strings when Sentry response fields are missing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifySentry({ + apiToken: 'sntrys_abc', + organizationSlug: 'my-org', + }); + + expect(result).toEqual({ id: '', name: '', slug: '' }); + }); + + it('wraps non-ok response in BAD_REQUEST', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'bad-token', organizationSlug: 'my-org' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('wraps network failure in BAD_REQUEST', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: 'my-org' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('URL-encodes the organization slug', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: '1', name: 'Org', slug: 'org-with-slash' }), + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.verifySentry({ apiToken: 'tok', organizationSlug: 'org/with/slash' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/org%2Fwith%2Fslash/', + expect.any(Object), + ); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError( + caller.verifySentry({ apiToken: 'tok', organizationSlug: 'my-org' }), + 'UNAUTHORIZED', + ); + }); + + it('rejects empty apiToken', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: '', organizationSlug: 'my-org' }), + ).rejects.toThrow(); + }); + + it('rejects empty organizationSlug', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: '' }), + ).rejects.toThrow(); + }); + }); }); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index aca393dd..fde2b880 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -387,6 +387,40 @@ describe('projectsRouter', () => { undefined, ); }); + + it('upserts alerting integration with category alerting', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpsertProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrations.upsert({ + projectId: 'p1', + category: 'alerting', + provider: 'sentry', + config: { organizationSlug: 'my-org' }, + }); + + expect(mockUpsertProjectIntegration).toHaveBeenCalledWith( + 'p1', + 'alerting', + 'sentry', + { organizationSlug: 'my-org' }, + undefined, + ); + }); + + it('rejects unknown category', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.integrations.upsert({ + projectId: 'p1', + // @ts-expect-error testing invalid category + category: 'unknown', + provider: 'sentry', + config: {}, + }), + ).rejects.toThrow(); + }); }); describe('delete', () => { @@ -399,6 +433,16 @@ describe('projectsRouter', () => { expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'pm'); }); + + it('deletes alerting integration', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockDeleteProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrations.delete({ projectId: 'p1', category: 'alerting' }); + + expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'alerting'); + }); }); }); diff --git a/tests/unit/router/adapters/github.test.ts b/tests/unit/router/adapters/github.test.ts index feecb059..029c2aa0 100644 --- a/tests/unit/router/adapters/github.test.ts +++ b/tests/unit/router/adapters/github.test.ts @@ -69,6 +69,10 @@ vi.mock('../../../../src/pm/trello/integration.js', () => ({ vi.mock('../../../../src/sentry.js', () => ({ captureException: vi.fn(), })); +vi.mock('../../../../src/utils/runLink.js', () => ({ + buildWorkItemRunsLink: vi.fn().mockReturnValue('\n\n🕵️ [View run](https://example.com)'), + getDashboardUrl: vi.fn().mockReturnValue('https://example.com'), +})); import { isPMFocusedAgent } from '../../../../src/agents/definitions/loader.js'; import { findProjectByRepo } from '../../../../src/config/provider.js'; @@ -86,6 +90,7 @@ import { addEyesReactionToPR } from '../../../../src/router/pre-actions.js'; import type { GitHubJob } from '../../../../src/router/queue.js'; import { sendAcknowledgeReaction } from '../../../../src/router/reactions.js'; import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; +import { buildWorkItemRunsLink } from '../../../../src/utils/runLink.js'; const mockProject: RouterProjectConfig = { id: 'p1', @@ -407,6 +412,40 @@ describe('GitHubRouterAdapter', () => { expect(postTrelloAck).toHaveBeenCalledWith('p1', 'trigger-card-id', expect.any(String)); }); + it('uses triggerResult.workItemId over event.workItemId for GitHub PR run link', async () => { + // Ensure isPMFocusedAgent returns false so we take the GitHub PR ack path, not PM path + vi.mocked(isPMFocusedAgent).mockResolvedValue(false); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', repo: 'owner/repo', runLinksEnabled: true } as never], + }); + vi.mocked(resolveGitHubTokenForAckByAgent).mockResolvedValue({ + token: 'ghp_test', + project: { id: 'p1' }, + } as never); + vi.mocked(extractPRNumber).mockReturnValue(42); + vi.mocked(postGitHubAck).mockResolvedValue(1); + + await adapter.postAck( + { + projectIdentifier: 'owner/repo', + eventType: 'pull_request', + workItemId: '1030', // PR number — should NOT be used for link + isCommentEvent: false, + // @ts-expect-error extended field + repoFullName: 'owner/repo', + }, + {}, + mockProject, + 'review', + { agentType: 'review', agentInput: {}, workItemId: 'trello-card-abc' }, + ); + + expect(buildWorkItemRunsLink).toHaveBeenCalledWith( + expect.objectContaining({ workItemId: 'trello-card-abc' }), + ); + }); + it('returns undefined for PM-focused agents when no workItemId available', async () => { vi.mocked(isPMFocusedAgent).mockResolvedValue(true); diff --git a/tests/unit/web/llm-call-detail.test.ts b/tests/unit/web/llm-call-detail.test.ts new file mode 100644 index 00000000..b311efd7 --- /dev/null +++ b/tests/unit/web/llm-call-detail.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { parseLlmResponse } from '../../../web/src/lib/llm-response-parser.js'; + +/** + * Tests for LLM call detail display logic, focusing on ThinkingBlock behavior. + * + * Note: React component rendering tests are not possible in the current test + * setup (node environment, no jsdom). These tests cover: + * - The parseLlmResponse function's handling of thinking blocks + * - The char count label logic used by ThinkingBlock + * - Verifying no "collapsing" logic exists at the data layer + */ + +// ─── ThinkingBlock char count label logic ──────────────────────────────────── + +describe('ThinkingBlock char count label', () => { + /** + * Mirrors the label logic from the ThinkingBlock component: + * `💭 Thinking ({text.length.toLocaleString()} chars)` + */ + function buildThinkingLabel(text: string): string { + return `💭 Thinking (${text.length.toLocaleString()} chars)`; + } + + it('shows correct char count for short thinking text', () => { + const text = 'Hello world'; + expect(buildThinkingLabel(text)).toBe('💭 Thinking (11 chars)'); + }); + + it('shows correct char count for empty thinking text', () => { + const text = ''; + expect(buildThinkingLabel(text)).toBe('💭 Thinking (0 chars)'); + }); + + it('uses toLocaleString formatting for large char counts', () => { + // 1000+ chars should format with locale separators (e.g. "1,000" in en-US) + const text = 'a'.repeat(1000); + const label = buildThinkingLabel(text); + // The exact format depends on locale, but it should contain "1" and "000" + expect(label).toContain('💭 Thinking ('); + expect(label).toContain(' chars)'); + // Should include the number 1000 in some locale-formatted form + expect(text.length).toBe(1000); + }); + + it('shows full char count without truncation for very large thinking text', () => { + // Simulates extended thinking mode — thousands of chars + const text = 'x'.repeat(5000); + const label = buildThinkingLabel(text); + expect(label).toContain('💭 Thinking ('); + expect(label).toContain(' chars)'); + expect(text.length).toBe(5000); + }); +}); + +// ─── parseLlmResponse: thinking block parsing ──────────────────────────────── + +describe('parseLlmResponse: thinking blocks are fully preserved', () => { + it('parses a single thinking block from Claude Code format', () => { + const thinkingText = 'I need to think about this carefully.'; + const response = JSON.stringify([{ type: 'thinking', thinking: thinkingText }]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0]).toEqual({ kind: 'thinking', text: thinkingText }); + }); + + it('preserves the full thinking text without truncation', () => { + // Simulate a large thinking block (extended thinking mode) + const thinkingText = 'Deep reasoning step. '.repeat(200); // ~4200 chars + const response = JSON.stringify([{ type: 'thinking', thinking: thinkingText }]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(1); + const block = result.blocks[0]; + expect(block.kind).toBe('thinking'); + if (block.kind === 'thinking') { + // Full text must be preserved — no truncation + expect(block.text).toBe(thinkingText); + expect(block.text.length).toBe(thinkingText.length); + } + }); + + it('parses thinking block alongside text and tool_use blocks', () => { + const response = JSON.stringify([ + { type: 'thinking', thinking: 'Let me consider the approach.' }, + { type: 'text', text: 'Here is my answer.' }, + { type: 'tool_use', name: 'Read', input: { file_path: '/foo/bar.ts' } }, + ]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(3); + expect(result.blocks[0]).toEqual({ + kind: 'thinking', + text: 'Let me consider the approach.', + }); + expect(result.blocks[1]).toEqual({ kind: 'text', text: 'Here is my answer.' }); + expect(result.blocks[2]).toMatchObject({ kind: 'tool_use', name: 'Read' }); + }); + + it('returns thinking block with kind === "thinking" (not collapsed or hidden)', () => { + const response = JSON.stringify([{ type: 'thinking', thinking: 'Some inner thoughts.' }]); + + const result = parseLlmResponse(response); + + // Verify the block kind is "thinking" — the component renders it inline + expect(result.blocks[0].kind).toBe('thinking'); + // Verify there is no intermediate "collapsed" representation + const block = result.blocks[0]; + if (block.kind === 'thinking') { + expect(typeof block.text).toBe('string'); + } + }); + + it('handles multiple thinking blocks in sequence', () => { + const response = JSON.stringify([ + { type: 'thinking', thinking: 'First thought.' }, + { type: 'thinking', thinking: 'Second thought.' }, + ]); + + const result = parseLlmResponse(response); + + expect(result.blocks).toHaveLength(2); + expect(result.blocks[0]).toEqual({ kind: 'thinking', text: 'First thought.' }); + expect(result.blocks[1]).toEqual({ kind: 'thinking', text: 'Second thought.' }); + }); +}); + +// ─── ThinkingBlock renders full content (structural verification) ───────────── + +describe('ThinkingBlock structural guarantees', () => { + /** + * The ThinkingBlock component no longer uses a
/ collapsible + * wrapper. These tests verify the logic that the component relies on ensures + * the full text is available (no truncation at parse layer). + */ + + it('thinking text length equals original text length (no truncation in parser)', () => { + const originalText = 'This is the complete thinking content without any abbreviation.'; + const response = JSON.stringify([{ type: 'thinking', thinking: originalText }]); + + const { blocks } = parseLlmResponse(response); + + const thinkingBlock = blocks.find((b) => b.kind === 'thinking'); + expect(thinkingBlock).toBeDefined(); + if (thinkingBlock && thinkingBlock.kind === 'thinking') { + expect(thinkingBlock.text.length).toBe(originalText.length); + } + }); + + it('toolNames does not include thinking blocks (thinking is not a tool)', () => { + const response = JSON.stringify([ + { type: 'thinking', thinking: 'Some thought.' }, + { type: 'tool_use', name: 'Bash', input: { command: 'ls' } }, + ]); + + const { toolNames } = parseLlmResponse(response); + + // Thinking blocks should not appear in toolNames + expect(toolNames).not.toContain('thinking'); + expect(toolNames).toContain('Bash'); + }); +}); diff --git a/web/src/components/llm-calls/llm-call-detail.tsx b/web/src/components/llm-calls/llm-call-detail.tsx index 45a4db96..c870522f 100644 --- a/web/src/components/llm-calls/llm-call-detail.tsx +++ b/web/src/components/llm-calls/llm-call-detail.tsx @@ -35,14 +35,14 @@ function ToolUseBlock({ name, inputSummary }: { name: string; inputSummary: stri function ThinkingBlock({ text }: { text: string }) { return ( -
- - Thinking ({text.length.toLocaleString()} chars) - +
+
+ 💭 Thinking ({text.length.toLocaleString()} chars) +
 				{text}
 			
-
+ ); } @@ -101,6 +101,8 @@ export function LlmCallDetail({ runId, callNumber }: LlmCallDetailProps) { {showRaw ? ( + // Raw view keeps max-h-96 as a compact scrollable JSON representation. + // The structured view intentionally shows all content fully expanded (no truncation).
 					{formatRawContent(call.response)}
 				
diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 44d77551..8e520258 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -18,7 +18,7 @@ import { useEffect, useState } from 'react'; import { PMWizard } from './pm-wizard.js'; import { ProjectSecretField } from './project-secret-field.js'; -type IntegrationCategory = 'pm' | 'scm'; +type IntegrationCategory = 'pm' | 'scm' | 'alerting'; // ============================================================================ // GitHub Credential Slots (replaces the old CredentialSelector dropdowns) @@ -433,6 +433,192 @@ function SCMTab({ ); } +// ============================================================================ +// Alerting Tab (Sentry) +// ============================================================================ + +interface AlertingTabProps { + projectId: string; + alertingIntegration?: Record; +} + +function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { + const queryClient = useQueryClient(); + + const existingConfig = (alertingIntegration?.config as Record) ?? {}; + const [organizationSlug, setOrganizationSlug] = useState( + (existingConfig.organizationSlug as string) ?? '', + ); + + const [verifyResult, setVerifyResult] = useState<{ + id: string; + name: string; + slug: string; + } | null>(null); + const [verifyError, setVerifyError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const sentryWebhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/sentry/webhook/${projectId}` + : `/sentry/webhook/${projectId}`; + + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + const credentials = credentialsQuery.data ?? []; + const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); + const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); + + const handleVerify = async (rawToken: string) => { + if (!rawToken) { + setVerifyError('Enter the API token value to verify it'); + return; + } + if (!organizationSlug) { + setVerifyError('Enter the organization slug to verify it'); + return; + } + setIsVerifying(true); + setVerifyError(null); + setVerifyResult(null); + try { + const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ + apiToken: rawToken, + organizationSlug, + }); + setVerifyResult(result); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : String(err)); + } finally { + setIsVerifying(false); + } + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'alerting', + provider: 'sentry', + config: { organizationSlug }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.delete.mutate({ + projectId, + category: 'alerting', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
+ {/* Organization Slug */} +
+ +

+ Your Sentry organization slug (found in your Sentry URL:{' '} + sentry.io/organizations/<slug>/). +

+ setOrganizationSlug(e.target.value)} + placeholder="my-organization" + /> +
+ +
+ + {/* Credentials */} +
+ + + +
+ +
+ + {/* Sentry Webhook URL */} +
+ +

+ Configure this URL in your Sentry project's webhook settings to receive alerts. +

+
+ {sentryWebhookUrl} + +
+
+ +
+ + {/* Save / Delete */} +
+ + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} + {alertingIntegration && ( + + )} + {deleteMutation.isError && ( + {deleteMutation.error.message} + )} +
+
+ ); +} + // ============================================================================ // Helpers // ============================================================================ @@ -489,6 +675,7 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const integrations = integrationsQuery.data ?? []; const pmIntegration = findIntegrationByCategory(integrations, 'pm'); const pmProvider = (pmIntegration?.provider as string) ?? 'trello'; + const alertingIntegration = findIntegrationByCategory(integrations, 'alerting'); return (
@@ -505,6 +692,12 @@ export function IntegrationForm({ projectId }: { projectId: string }) { activeTab={activeTab} onClick={() => setActiveTab('scm')} /> + setActiveTab('alerting')} + />
{activeTab === 'pm' && ( @@ -516,6 +709,10 @@ export function IntegrationForm({ projectId }: { projectId: string }) { )} {activeTab === 'scm' && } + + {activeTab === 'alerting' && ( + + )} ); }