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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/tool-dispatch-smoke-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
---

test(training-agent): add handler-dispatch smoke test per tool per tenant — closes #3978

Extends CI façade detection beyond name-match to dispatch coverage.
`tool-catalog-drift.test.ts` proves tool names match the catalog; this new test
proves each handler is wired: for every (tenant, tool) pair a `tools/call` with
minimal arguments must not return `NOT_IMPLEMENTED` or `UNSUPPORTED_FEATURE`.
Domain errors (`INVALID_REQUEST`, `MEDIA_BUY_NOT_FOUND`, …) are expected passes —
those confirm the handler ran.

The gap surfaced by #3962/#3976: `list_creative_formats` was advertised on
`/creative` and `/creative-builder` but the v6 platform method was missing.
Catalog drift test passed (names matched); the storyboard caught it only after a
buyer-side request was made. This test would have caught it at CI time.

**Files:**
- `server/tests/integration/training-agent-tool-dispatch-smoke.test.ts` (new)

**Implementation notes:**
- Mirrors the drift test's server-boot and MCP-over-HTTP call pattern.
- Uses `{}` as arguments for read-only tools; adds `idempotency_key` for mutating
tools so the framework routes to the handler body.
- Does not attempt response-schema validation — `responseSchema` is not exposed on
the advertised tool object (`tools/list` returns `inputSchema` only).
- Two assertion forms: `structuredContent.adcp_error.code` for AdcpError-wrapped
NOT_IMPLEMENTED, plus a text-content regex check for SDK-level UNSUPPORTED_FEATURE
strings that don't follow the adcp_error envelope.
157 changes: 157 additions & 0 deletions server/tests/integration/training-agent-tool-dispatch-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Dispatch smoke test for the training agent.
*
* `tool-catalog-drift.test.ts` proves the names match. This test proves
* the dispatch path is wired: for every (tenant, tool) pair in the catalog,
* a `tools/call` with minimal arguments must NOT return NOT_IMPLEMENTED or
* UNSUPPORTED_FEATURE. Domain errors (MEDIA_BUY_NOT_FOUND, INVALID_REQUEST,
* etc.) count as passing — the handler ran.
*
* Façade class caught: a platform that advertises every tool in its catalog
* (passes drift test) but has one or more handlers missing or stubbed-out.
* This class escaped CI in #3962 (list_creative_formats on /creative +
* /creative-builder was advertised but unimplemented until #3976).
*/

import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import http from 'node:http';
import { AddressInfo } from 'node:net';
import crypto from 'node:crypto';

vi.hoisted(() => {
process.env.PUBLIC_TEST_AGENT_TOKEN = 'tool-dispatch-smoke-token';
process.env.NODE_ENV = 'test';
});

vi.mock('../../src/logger.js', () => ({
createLogger: () => ({
info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(),
}),
}));

const { createTrainingAgentRouter } = await import('../../src/training-agent/index.js');
const { stopSessionCleanup } = await import('../../src/training-agent/state.js');
const { toolsForTenant } = await import('../../src/training-agent/tenants/tool-catalog.js');
const { MUTATING_TOOLS } = await import('../../src/training-agent/idempotency.js');

const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] as const;
type TenantId = typeof TENANT_IDS[number];
const AUTH = 'Bearer tool-dispatch-smoke-token';

interface MCPResponse {
error?: unknown;
result?: {
content?: Array<{ type: string; text?: string }>;
structuredContent?: Record<string, unknown>;
isError?: boolean;
};
}

async function callTool(baseUrl: string, tenantId: string, toolName: string): Promise<MCPResponse> {
const url = `${baseUrl}/api/training-agent/${tenantId}/mcp`;
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
authorization: AUTH,
};
// Initialize handshake — required before tools/call. Verify it succeeds so
// a server-boot or auth failure surfaces here rather than as a phantom pass.
const initRes = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2025-03-26', clientInfo: { name: 'smoke', version: '1' }, capabilities: {} },
}),
});
const initBody = await initRes.json() as MCPResponse;
if (initBody.error) {
throw new Error(`initialize failed for ${tenantId}: ${JSON.stringify(initBody.error)}`);
}
// Minimal arguments: mutating tools need an idempotency_key so the
// framework routes to the handler (missing key → framework validation
// error before dispatch, which would also not be NOT_IMPLEMENTED, but
// an idempotency_key lets us reach the handler body cleanly).
const args: Record<string, unknown> = MUTATING_TOOLS.has(toolName)
? { idempotency_key: `smoke-${crypto.randomUUID()}` }
: {};
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: toolName, arguments: args },
}),
});
return res.json() as Promise<MCPResponse>;
}

// Build the full test matrix once from the catalog.
const TEST_CASES: Array<[TenantId, string]> = TENANT_IDS.flatMap(
tenantId => toolsForTenant(tenantId).map(tool => [tenantId, tool] as [TenantId, string]),
);

describe('tool dispatch smoke', () => {
let server: http.Server;
let baseUrl: string;

beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/training-agent', createTrainingAgentRouter());
server = http.createServer(app);
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', () => resolve()));
const port = (server.address() as AddressInfo).port;
baseUrl = `http://127.0.0.1:${port}`;
});

afterAll(async () => {
stopSessionCleanup();
await new Promise<void>(resolve => server.close(() => resolve()));
});

// SDK bug allowlist — adcontextprotocol/adcp-client#1688.
// The SDK auto-advertises these tools on /creative-builder via specialism
// membership, but CreativeBuilderPlatform (stateless build/transform) has
// no method slots for them — they live on CreativeAdServerPlatform.
// Calls correctly return UNSUPPORTED_FEATURE today. Remove this allowlist
// entry once the SDK narrows tool advertisement to interface method
// presence; the test will then catch any future regression naturally.
const SDK_AUTO_ADVERTISED_UNIMPLEMENTED = new Set<string>([
'creative-builder/list_creatives',
'creative-builder/get_creative_delivery',
]);

it.each(TEST_CASES)('%s / %s handler is wired (no NOT_IMPLEMENTED)', async (tenantId, toolName) => {
const body = await callTool(baseUrl, tenantId, toolName);

const structured = body.result?.structuredContent;
const errorCode = (structured?.adcp_error as Record<string, unknown> | undefined)?.code;
const knownSdkBug = SDK_AUTO_ADVERTISED_UNIMPLEMENTED.has(`${tenantId}/${toolName}`);

// Domain errors (INVALID_REQUEST, MEDIA_BUY_NOT_FOUND, …) are expected
// and count as passing — those mean the handler ran. Only NOT_IMPLEMENTED
// and UNSUPPORTED_FEATURE indicate the dispatch path is unwired.
expect(
errorCode,
`${tenantId}/${toolName}: handler returned NOT_IMPLEMENTED — add the method to the v6 platform class`,
).not.toBe('NOT_IMPLEMENTED');

if (!knownSdkBug) {
expect(
errorCode,
`${tenantId}/${toolName}: handler returned UNSUPPORTED_FEATURE — wire the method in the platform class`,
).not.toBe('UNSUPPORTED_FEATURE');

// SDK-level UNSUPPORTED_FEATURE surfaces as a text string, not an
// adcp_error envelope. Check the raw content text as a belt-and-suspenders
// guard so the test catches both the AdcpError and SDK-wrapper forms.
const textContent = body.result?.content?.[0]?.text ?? '';
expect(
textContent,
`${tenantId}/${toolName}: SDK returned UNSUPPORTED_FEATURE — method missing from platform class`,
).not.toMatch(/UNSUPPORTED_FEATURE/);
}
}, 15000);
});
Loading