diff --git a/.changeset/add-si-training-tenant.md b/.changeset/add-si-training-tenant.md new file mode 100644 index 0000000000..14bfc37559 --- /dev/null +++ b/.changeset/add-si-training-tenant.md @@ -0,0 +1,28 @@ +--- +--- + +feat(training-agent): add `/si` tenant serving SI lifecycle tools (#3940) + +Adds a new `si` training-agent tenant at `/si/mcp` that simulates the +brand-agent side of the Sponsored Intelligence session lifecycle. Learners +can now complete the S5 specialist capstone and C3 creative-SI exercises +without hitting `Unknown tool: si_initiate_session`. + +**What ships:** +- `v6-si-platform.ts` — minimal `DecisioningPlatform` (no specialism + methods; all four SI tools ride `customTools` pending SDK SI interface). +- `tenants/si.ts` — stub handlers for `si_get_offering`, + `si_initiate_session`, `si_send_message`, `si_terminate_session` + (Nova Brands training fixture, in-memory session state). +- `tenants/registry.ts` — registers the `si` tenant alongside the + existing six. +- `tenants/tool-catalog.ts` — adds `si_*` → `['si']` discovery hints. +- Migration 465 — backfills `tenant_ids` for A3, C3, S5; fixes the C3 + `c3_ex2` phantom tool (`connect_to_si_agent` → `si_initiate_session`); + appends stable recertification criterion IDs to S5's `s5_ex1`. + +**Not included (human task):** +- Storyboard floors in `.github/workflows/training-agent-storyboards.yml` + for the `si` tenant — add to smoke matrix after this merges. + +Refs #3940. diff --git a/server/src/db/migrations/465_si_tenant_module_pins.sql b/server/src/db/migrations/465_si_tenant_module_pins.sql new file mode 100644 index 0000000000..1d07c56520 --- /dev/null +++ b/server/src/db/migrations/465_si_tenant_module_pins.sql @@ -0,0 +1,198 @@ +-- Pin A3, C3, and S5 to the new `si` training-agent tenant, which was +-- explicitly left NULL in migration 464 because no `si_*`-serving tenant +-- existed at the time. This migration follows once the tenant ships. +-- +-- Additional curriculum fixes bundled here (education-expert review of #3940): +-- +-- 1. C3 c3_ex2 phantom tool: `connect_to_si_agent` is an Addie-specific host +-- tool (server/src/addie/mcp/si-host-tools.ts), not a protocol-defined +-- AdCP task. The training agent has never served it. Replace with +-- `si_initiate_session` — the correct entry point for the SI lifecycle. +-- +-- 2. S5 s5_ex1 sandbox_actions gap: migration 303 rewrote S5's sandbox_actions +-- to include si_initiate_session and si_send_message but omitted +-- si_get_offering and si_terminate_session. The two new criterion IDs +-- (s5_ex1_sc_session_lifecycle, s5_ex1_sc_offering_integration) require +-- both tools to be prompted actions — adding criterion IDs for behaviors +-- the exercise never asks the learner to perform fails the ASTM E3416-24 +-- required-demonstration test. Append both tools to sandbox_actions first. +-- +-- 3. S5 criterion IDs: The s5_ex1 success_criteria were defined as plain text +-- strings (migration 270 seed, overwritten verbatim by migrations 303/298). +-- The _append_criterion helper (migration 407) adds stable IDs the +-- recertification engine can target when SI experimental surfaces change. +-- Adds s5_ex1_sc_session_lifecycle and s5_ex1_sc_offering_integration — +-- the two criteria covering the graded competencies at highest assessment +-- weight in S5's protocol_mastery dimension. + +-- ── 1. Pin tenant_ids ─────────────────────────────────────────────────────── + +-- A3: add si to the landscape tour (the tour already lists the other six +-- tenants from migration 464; si is the missing stop). +UPDATE certification_modules + SET tenant_ids = ARRAY['sales', 'signals', 'governance', 'creative', 'brand', 'si'] + WHERE id = 'A3' AND tenant_ids IS NULL; + +-- C3: creative + sponsored intelligence — si is the primary SI surface; +-- creative and brand remain for sync_creatives / creative_approval exercises. +-- Drops creative-builder (per #3930 review: creative-builder is S2-specific). +UPDATE certification_modules + SET tenant_ids = ARRAY['creative', 'brand', 'si'] + WHERE id = 'C3' AND tenant_ids IS NULL; + +-- S5: specialist deep-dive — si is the sole primary tenant. brand was +-- considered (creative_approval for commerce handoff) but excluded: the +-- si_terminate_session reason enum already covers handoff_transaction and +-- handoff_complete as first-class termination reasons, and creative_approval +-- does not appear in S5's sandbox_actions. The ACP checkout handoff is +-- post-session and out of scope for the SI lifecycle exercise. +UPDATE certification_modules + SET tenant_ids = ARRAY['si'] + WHERE id = 'S5' AND tenant_ids IS NULL; + +-- ── 2. Fix C3 c3_ex2 phantom tool ────────────────────────────────────────── +-- +-- Replace `connect_to_si_agent` in sandbox_actions with `si_initiate_session`. +-- The exercise intent (connecting to a brand SI agent) is preserved; only the +-- tool name and guidance text change to match the actual protocol tool. +-- Safe to replay: the WHERE guard matches only when the phantom name is present. + +UPDATE certification_modules + SET exercise_definitions = ( + SELECT jsonb_agg( + CASE + WHEN ex->>'id' = 'c3_ex2' + THEN jsonb_set( + ex, + '{sandbox_actions}', + ( + SELECT jsonb_agg( + CASE + WHEN act->>'tool' = 'connect_to_si_agent' + THEN jsonb_build_object( + 'tool', 'si_initiate_session', + 'guidance', 'Initiate a session with the training SI brand agent. Provide an intent describing what the user is looking for. Examine the session_id, negotiated_capabilities, and the brand''s opening message — note how the brand already incorporates your intent into its response. That bidirectional personalization is what distinguishes SI from impression-based formats.' + ) + ELSE act + END + ) + FROM jsonb_array_elements(ex->'sandbox_actions') act + ) + ) + ELSE ex + END + ) + FROM jsonb_array_elements(exercise_definitions) ex + ) + WHERE id = 'C3' + AND exercise_definitions::text LIKE '%connect_to_si_agent%'; + +-- ── 3. S5 s5_ex1 sandbox_actions: add missing si_get_offering and si_terminate_session +-- +-- Migration 303 wrote si_initiate_session and si_send_message but omitted the +-- bookend tools. Both must be present as prompted actions before the criterion +-- IDs in section 4 can be treated as demonstrated competencies under +-- ASTM E3416-24. Safe to replay: WHERE guard requires the current actions list +-- to lack si_get_offering (idempotent). + +UPDATE certification_modules + SET exercise_definitions = ( + SELECT jsonb_agg( + CASE + WHEN ex->>'id' = 's5_ex1' + THEN jsonb_set( + ex, + '{sandbox_actions}', + ex->'sandbox_actions' + || jsonb_build_array( + jsonb_build_object( + 'tool', 'si_get_offering', + 'guidance', 'Call si_get_offering with the training brand''s offering_id. Examine the returned offering_token — you will pass it to si_initiate_session to demonstrate session-continuity handoff.' + ), + jsonb_build_object( + 'tool', 'si_terminate_session', + 'guidance', 'Terminate the session with an appropriate reason code (user_exit, handoff_transaction, or handoff_complete). Verify turns_completed in the response.' + ) + ) + ) + ELSE ex + END + ) + FROM jsonb_array_elements(exercise_definitions) ex + ) + WHERE id = 'S5' + AND exercise_definitions::text NOT LIKE '%si_get_offering%'; + +-- ── 4. S5 stable criterion IDs ────────────────────────────────────────────── +-- +-- Re-declare the _append_criterion helper (CREATE OR REPLACE — idempotent) +-- then stamp two semantic IDs onto s5_ex1. These IDs let the recertification +-- engine identify credential holders who need re-assessment when the SI +-- experimental surface changes. + +CREATE OR REPLACE FUNCTION _append_criterion( + p_module_id text, + p_exercise_id text, + p_criterion_id text, + p_text text +) RETURNS void AS $$ +DECLARE + defs jsonb; + updated jsonb := '[]'::jsonb; + ex jsonb; + criteria jsonb; + already_present boolean; + exercise_matched boolean := false; +BEGIN + SELECT exercise_definitions INTO defs + FROM certification_modules + WHERE id = p_module_id; + + IF defs IS NULL OR jsonb_typeof(defs) <> 'array' THEN + RAISE EXCEPTION 'Module % not found or has no exercise_definitions array', p_module_id; + END IF; + + FOR ex IN SELECT * FROM jsonb_array_elements(defs) + LOOP + IF ex->>'id' = p_exercise_id THEN + exercise_matched := true; + criteria := COALESCE(ex->'success_criteria', '[]'::jsonb); + + SELECT EXISTS ( + SELECT 1 FROM jsonb_array_elements(criteria) c + WHERE c->>'id' = p_criterion_id + ) INTO already_present; + + IF NOT already_present THEN + criteria := criteria || jsonb_build_array( + jsonb_build_object('id', p_criterion_id, 'text', p_text) + ); + ex := jsonb_set(ex, '{success_criteria}', criteria); + END IF; + END IF; + updated := updated || jsonb_build_array(ex); + END LOOP; + + IF NOT exercise_matched THEN + RAISE EXCEPTION 'Exercise % not found in module %', p_exercise_id, p_module_id; + END IF; + + UPDATE certification_modules + SET exercise_definitions = updated + WHERE id = p_module_id; +END; +$$ LANGUAGE plpgsql; + +SELECT _append_criterion( + 'S5', + 's5_ex1', + 's5_ex1_sc_session_lifecycle', + 'Demonstrates the full SI session lifecycle: calls si_get_offering, si_initiate_session, si_send_message (at least one turn), and si_terminate_session in correct protocol order.' +); + +SELECT _append_criterion( + 'S5', + 's5_ex1', + 's5_ex1_sc_offering_integration', + 'Uses si_get_offering before session initiation and passes the returned offering_token to si_initiate_session, demonstrating the session-continuity handoff.' +); diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index 88b9ac7967..097dde1c92 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -1,8 +1,8 @@ /** * Multi-tenant TenantRegistry setup. * - * Six per-specialism tenants — `/sales`, `/signals`, `/governance`, - * `/creative`, `/creative-builder`, `/brand` — each with its own + * Seven per-specialism tenants — `/sales`, `/signals`, `/governance`, + * `/creative`, `/creative-builder`, `/brand`, `/si` — each with its own * `DecisioningPlatform` impl, ephemeral signing key, and specialism * declarations. Path-routed: tenants register with `agentUrl` like * `${CANONICAL_BASE}/`, the router binds tenantId at route @@ -41,6 +41,7 @@ import { buildGovernanceTenantConfig } from './governance.js'; import { buildCreativeTenantConfig } from './creative.js'; import { buildCreativeBuilderTenantConfig } from './creative-builder.js'; import { buildBrandTenantConfig } from './brand.js'; +import { buildSiTenantConfig } from './si.js'; import { createLogger } from '../../logger.js'; const logger = createLogger('training-agent-tenants'); @@ -212,6 +213,7 @@ export function createRegistryHolder(): RegistryHolder { const creative = buildCreativeTenantConfig(hostBase); const creativeBuilder = buildCreativeBuilderTenantConfig(hostBase); const brand = buildBrandTenantConfig(hostBase); + const si = buildSiTenantConfig(hostBase); // awaitFirstValidation:true blocks until the no-op validator // promotes the tenant to 'healthy'. Without it the first request // would race the background validation and see 'pending' (refused @@ -223,9 +225,10 @@ export function createRegistryHolder(): RegistryHolder { reg.register(creative.tenantId, creative.config, { awaitFirstValidation: true }), reg.register(creativeBuilder.tenantId, creativeBuilder.config, { awaitFirstValidation: true }), reg.register(brand.tenantId, brand.config, { awaitFirstValidation: true }), + reg.register(si.tenantId, si.config, { awaitFirstValidation: true }), ]); logger.info( - { hostBase, tenants: ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] }, + { hostBase, tenants: ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand', 'si'] }, 'Tenant registry initialized', ); registry = reg; diff --git a/server/src/training-agent/tenants/si.ts b/server/src/training-agent/tenants/si.ts new file mode 100644 index 0000000000..631064b73b --- /dev/null +++ b/server/src/training-agent/tenants/si.ts @@ -0,0 +1,261 @@ +/** + * /si tenant — Sponsored Intelligence specialism. + * + * Simulates the BRAND-AGENT side of the SI lifecycle so a learner can + * practice as the HOST: get an offering, initiate a session, exchange + * messages, and terminate. The training brand is Nova Brands (character + * bible, summer collection fixture). + * + * All four SI tools ride customTools because the SDK's DecisioningPlatform + * interface has no `sponsoredIntelligence` field yet. They ARE in AdcpToolMap + * (area: 'si') — the merge-seam comment in brand.ts describes the same + * pattern for update_rights / creative_approval. + * + * Session state is in-memory per process. Acceptable for the shared sandbox: + * session IDs are training-scoped and not persisted across restarts. + */ + +import { z } from 'zod'; +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingSiPlatform } from '../v6-si-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { customToolFor } from './custom-tool-helper.js'; +import type { ToolArgs, TrainingContext } from '../types.js'; + +const TENANT_ID = 'si'; + +const TRAINING_BRAND = { + name: 'Nova Brands', + domain: 'novabrands.example', + offering_id: 'nova-summer-2026', + product_title: 'Nova Summer Collection', +} as const; + +const activeSessions = new Map(); + +const CONTEXT_REF = z.any().optional(); + +const SI_GET_OFFERING_SCHEMA = { + offering_id: z.string(), + intent: z.string().optional(), + include_products: z.boolean().optional(), + product_limit: z.number().int().min(1).max(50).optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +const SI_INITIATE_SESSION_SCHEMA = { + idempotency_key: z.string().min(16).max(255).regex(/^[A-Za-z0-9_.:-]{16,255}$/), + intent: z.string(), + identity: z.object({}).passthrough(), + media_buy_id: z.string().optional(), + placement: z.string().optional(), + offering_id: z.string().optional(), + supported_capabilities: z.object({}).passthrough().optional(), + offering_token: z.string().optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +const SI_SEND_MESSAGE_SCHEMA = { + idempotency_key: z.string().min(16).max(255).regex(/^[A-Za-z0-9_.:-]{16,255}$/), + session_id: z.string(), + message: z.string().optional(), + action_response: z.object({}).passthrough().optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +const SI_TERMINATE_SESSION_SCHEMA = { + session_id: z.string(), + reason: z.enum([ + 'handoff_transaction', + 'handoff_complete', + 'user_exit', + 'session_timeout', + 'host_terminated', + ]), + termination_context: z.object({}).passthrough().optional(), + context: CONTEXT_REF, + ext: z.any().optional(), +}; + +function makeSessionId(): string { + return `si_sess_${crypto.randomUUID()}`; +} + +export function buildSiTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — sponsored intelligence', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingSiPlatform() as any, + serverOptions: { + customTools: { + si_get_offering: customToolFor( + 'si_get_offering', + 'Get offering details and availability before initiating a session.', + SI_GET_OFFERING_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const includeProducts = Boolean(p['include_products'] ?? false); + const offeringId = String(p['offering_id'] ?? TRAINING_BRAND.offering_id).slice(0, 128); + const tokenSuffix = offeringId.replace(/[^a-z0-9]/gi, '_'); + return { + available: true, + offering_token: `otk_${crypto.randomUUID()}_${tokenSuffix}`, + offering: { + offering_id: offeringId, + title: TRAINING_BRAND.product_title, + summary: + 'Discover the latest Nova summer styles — conversational shopping powered by Sponsored Intelligence.', + }, + ...(includeProducts && { + matching_products: [ + { + product_id: 'nova-sun-dress-001', + name: 'Nova Sun Dress', + price: '79.00', + currency: 'USD', + }, + { + product_id: 'nova-linen-blazer-002', + name: 'Nova Linen Blazer', + price: '129.00', + currency: 'USD', + }, + ], + }), + }; + }, + ), + + si_initiate_session: customToolFor( + 'si_initiate_session', + 'Start a conversational session with a brand agent.', + SI_INITIATE_SESSION_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const sessionId = makeSessionId(); + activeSessions.set(sessionId, { turnCount: 0, terminated: false }); + const intent = String(p['intent'] ?? 'browse').slice(0, 200); + return { + session_id: sessionId, + session_status: 'active', + session_ttl_seconds: 300, + negotiated_capabilities: { + modalities: { conversational: true }, + components: { + standard: ['text', 'link', 'product_card', 'action_button'], + }, + }, + response: { + message: `Hi! I'm ${TRAINING_BRAND.name}'s AI. You mentioned: "${intent}". I'd love to help you find the perfect piece from our summer collection — what style are you looking for?`, + ui_elements: [ + { + type: 'product_card', + product_id: 'nova-sun-dress-001', + title: 'Nova Sun Dress', + price: '79.00', + currency: 'USD', + }, + ], + }, + }; + }, + ), + + si_send_message: customToolFor( + 'si_send_message', + 'Send a message to an active brand agent session.', + SI_SEND_MESSAGE_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const sessionId = String(p['session_id'] ?? ''); + const session = activeSessions.get(sessionId); + if (!session) { + return { + errors: [ + { + code: 'SESSION_NOT_FOUND', + message: + 'SI session not found or expired. Initiate a new session via si_initiate_session.', + recovery: 'correctable', + }, + ], + }; + } + if (session.terminated) { + return { + errors: [ + { + code: 'SESSION_TERMINATED', + message: + 'SI session has already been terminated. Initiate a new session via si_initiate_session.', + recovery: 'correctable', + }, + ], + }; + } + session.turnCount++; + const msg = String(p['message'] ?? '').slice(0, 200); + return { + session_id: sessionId, + session_status: 'active', + response: { + message: + session.turnCount === 1 + ? `Great question! Our ${TRAINING_BRAND.product_title} has something for every style. Here are some popular picks — would you like to see more or go straight to checkout?` + : `Thanks for sharing more context: "${msg || '(no message)'}". Based on that, I'd recommend our Nova Linen Blazer — it's our most versatile summer piece. Want details or would you like to proceed to checkout?`, + ui_elements: [ + { + type: 'action_button', + label: 'View full collection', + action: 'view_collection', + }, + { + type: 'action_button', + label: 'Proceed to checkout', + action: 'checkout', + }, + ], + }, + }; + }, + ), + + si_terminate_session: customToolFor( + 'si_terminate_session', + 'End an active brand agent session.', + SI_TERMINATE_SESSION_SCHEMA, + async (args: ToolArgs, _ctx: TrainingContext) => { + const p = args as Record; + const sessionId = String(p['session_id'] ?? ''); + const session = activeSessions.get(sessionId); + const turns = session?.turnCount ?? 0; + if (session) { + session.terminated = true; + } + return { + session_id: sessionId, + terminated: true, + session_status: 'terminated', + termination_summary: { + reason: String(p['reason'] ?? 'user_exit'), + turns_completed: turns, + }, + }; + }, + ), + }, + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 4399b951ef..9691eba532 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -78,7 +78,7 @@ describe('tenant routing smoke', () => { expect(body.version).toBe('1.0'); expect(Array.isArray(body.agents)).toBe(true); const ids = body.agents.map(a => a.agent_id).sort(); - expect(ids).toEqual(['brand', 'creative', 'creative-builder', 'governance', 'sales', 'signals']); + expect(ids).toEqual(['brand', 'creative', 'creative-builder', 'governance', 'sales', 'si', 'signals']); const sales = body.agents.find(a => a.agent_id === 'sales'); expect(sales?.transport).toBe('mcp'); expect(sales?.url).toMatch(/\/sales\/mcp$/); diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 9ae3533b14..59b3962fb2 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -76,6 +76,12 @@ export const TOOL_CATALOG: Readonly> = { acquire_rights: ['brand'], update_rights: ['brand'], creative_approval: ['brand'], + + // sponsored intelligence + si_get_offering: ['si'], + si_initiate_session: ['si'], + si_send_message: ['si'], + si_terminate_session: ['si'], }; /** Build the tool list a given tenant serves — inverse view of TOOL_CATALOG. */ diff --git a/server/src/training-agent/v6-si-platform.ts b/server/src/training-agent/v6-si-platform.ts new file mode 100644 index 0000000000..0d73f334ed --- /dev/null +++ b/server/src/training-agent/v6-si-platform.ts @@ -0,0 +1,76 @@ +/** + * v6 platform for the `/si` tenant — Sponsored Intelligence. + * + * Minimal platform: no specialism methods. All four SI lifecycle tools + * (si_get_offering, si_initiate_session, si_send_message, si_terminate_session) + * ride the customTools merge seam in tenants/si.ts — they're in AdcpToolMap + * (area: 'si') but the SDK's DecisioningPlatform interface has no SI field + * yet. + * + * This tenant simulates the BRAND-AGENT side of the SI lifecycle. A learner + * practices as the HOST — they initiate sessions, exchange messages, retrieve + * offerings, and terminate sessions against a deterministic training brand + * (Nova Brands). The host-outbound `connect_to_si_agent` tool that Addie uses + * to call real brand SI agents lives in addie/mcp/si-host-tools.ts and is a + * different surface entirely. + */ + +import { + type DecisioningPlatform, + type AccountStore, +} from '@adcp/sdk/server'; + +export interface TrainingSiConfig { + strict: boolean; +} + +export interface TrainingSiMeta { + [key: string]: unknown; +} + +const trainingSiAccounts: AccountStore = { + resolution: 'explicit', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: async (ref: any, _ctx: any) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ctx_metadata: {}, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingSiPlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: [] as const, + creative_agents: [] as const, + channels: [] as const, + pricingModels: [] as const, + supportedBillings: ['agent', 'operator'] as const, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingSiAccounts; +} diff --git a/server/tests/integration/training-agent-tool-catalog-drift.test.ts b/server/tests/integration/training-agent-tool-catalog-drift.test.ts index 4f076b4581..0e3437d1e0 100644 --- a/server/tests/integration/training-agent-tool-catalog-drift.test.ts +++ b/server/tests/integration/training-agent-tool-catalog-drift.test.ts @@ -36,7 +36,7 @@ const { createTrainingAgentRouter } = await import('../../src/training-agent/ind const { stopSessionCleanup } = await import('../../src/training-agent/state.js'); const { toolsForTenant, TOOL_CATALOG } = await import('../../src/training-agent/tenants/tool-catalog.js'); -const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] as const; +const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand', 'si'] as const; const AUTH = 'Bearer tool-catalog-drift-token'; interface ToolListResponse {