diff --git a/.changeset/app-default-agent-platform-binding.md b/.changeset/app-default-agent-platform-binding.md new file mode 100644 index 00000000..4ff69726 --- /dev/null +++ b/.changeset/app-default-agent-platform-binding.md @@ -0,0 +1,17 @@ +--- +'hotcrm': patch +--- + +Bind the CRM app's ambient chat to the platform `ask` agent. `crm_enterprise` +still declared `defaultAgent: 'sales_copilot'`, an app-authored agent retired in +#512 — per ADR-0063 §1/§2 the key is a surface binding whose only resolvable +values are the two platform agents (`ask` for data surfaces, `build` for +authoring surfaces), so the runtime's `loadAgent()` refused the record and the +floating chatbot resolved to nothing. Nothing caught it: `defaultAgent` accepts +any well-formed snake_case name, and the platform's agent lint only walks +authored agents. HotCRM's six shipped skills already attach to `ask` by +`surface` affinity, so the assistant now answers with its full skill set. +Adds a guard to `test/metadata-references.test.ts` that pins every app's +`defaultAgent` against the platform agent set read straight off the spec's +`AgentSchema`, so the next dangling binding fails locally instead of in a demo. +Refs #586. diff --git a/src/apps/crm.app.ts b/src/apps/crm.app.ts index 827ee53b..103fa428 100644 --- a/src/apps/crm.app.ts +++ b/src/apps/crm.app.ts @@ -15,7 +15,13 @@ export const CrmApp = App.create({ name: 'crm_enterprise', label: 'HotCRM', icon: 'briefcase', - defaultAgent: 'sales_copilot', + // ADR-0063 §1/§2 — `defaultAgent` is a surface binding, not a custom-agent + // slot: the only resolvable values are the two PLATFORM agents, `ask` (data + // surface) and `build` (authoring surface). HotCRM is a data surface, so it + // binds `ask`; the app's own AI capability ships as skills (`src/skills/`), + // which attach to the platform agents by `surface` affinity. The app-authored + // agents this key used to name were retired in #512. + defaultAgent: 'ask', branding: { primaryColor: '#4169E1', logo: '/assets/crm-logo.png', diff --git a/src/pages/account_detail.page.ts b/src/pages/account_detail.page.ts index a9d6385d..677e4cf2 100644 --- a/src/pages/account_detail.page.ts +++ b/src/pages/account_detail.page.ts @@ -22,8 +22,8 @@ import type { Page } from '@objectstack/spec/ui'; * Why not embed an inline chat panel? `ai:chat_window` was dropped * from objectui (see @object-ui/layout CHANGELOG 5.x). The supported * surface is the global floating chatbot mounted by app-shell, which - * picks up `defaultAgent: 'sales_copilot'` from this app and is - * launched from the FAB at the bottom-right. + * picks up `defaultAgent: 'ask'` from this app and is launched from + * the FAB at the bottom-right. */ export const AccountDetailPage = { name: 'account_detail_page', diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index a749b96e..f6b910d0 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { isDateMacroToken } from '@objectstack/spec/data'; +import { AgentSchema } from '@objectstack/spec/ai'; import stack from '../objectstack.config'; /** @@ -1283,3 +1284,69 @@ describe('form views do not declare a dead data provider', () => { expect(unresolved, `views whose object no longer resolves:\n ${unresolved.join('\n ')}`).toEqual([]); }); }); + +/** + * `App.defaultAgent` is a SURFACE BINDING, not a custom-agent slot. + * + * ADR-0063 §1/§2: the kernel ships exactly two agents — `ask` (the data + * surface, the implicit default) and `build` (authoring surfaces such as + * Studio). Tenant/app-package custom agents were withdrawn, and HotCRM's own + * app-authored agents were retired in #512; the app's AI capability now ships + * as skills, which attach to a platform agent by `surface` affinity. + * + * Nothing caught the dangling binding this replaces. `App.defaultAgent` is + * typed `SnakeCaseIdentifierSchema` — any well-formed snake_case name parses — + * so `os validate`, `pnpm build` and the platform's agent lint (which only + * walks `stack.agents`) all stayed green while `defaultAgent: 'sales_copilot'` + * pointed at an agent that had not existed for months. The failure is silent + * and late: `loadAgent()` refuses the non-platform record at chat time, so the + * only symptom is the ambient chatbot not answering in a demo. + * + * The platform set is READ FROM THE SPEC (`AgentSchema.shape.surface`), not + * transcribed here — the agent names and the surface names are the same two + * tokens, so this guard tracks the contract instead of drifting from it. + */ +describe('app AI bindings resolve to a platform agent', () => { + const apps: AnyRec[] = (stack as any).apps ?? []; + + /** `['ask', 'build']` — straight off the spec's own surface enum. */ + const PLATFORM_AGENTS: string[] = (() => { + const surface = (AgentSchema as AnyRec).shape.surface; + const enumSchema = typeof surface.removeDefault === 'function' ? surface.removeDefault() : surface; + return enumSchema.options as string[]; + })(); + + it('the spec still exposes exactly the two platform agents', () => { + // Guard the guard: if this introspection ever returns [] the checks below + // would pass by asserting nothing (or fail for the wrong reason). + expect(PLATFORM_AGENTS).toEqual(['ask', 'build']); + }); + + it('every app defaultAgent names a platform agent', () => { + expect(apps.length, 'no apps found in the stack — the guard is vacuous').toBeGreaterThan(0); + const bad: string[] = []; + for (const app of apps) { + const agent = app.defaultAgent; + if (agent === undefined) continue; // omitting the key is legal — `ask` is implicit + if (!PLATFORM_AGENTS.includes(agent)) { + bad.push( + `${app.name}: defaultAgent "${agent}" is not a platform agent ` + + `(${PLATFORM_AGENTS.join(' | ')}) — it will not resolve at chat time`, + ); + } + } + expect(bad, `dangling app agent bindings:\n ${bad.join('\n ')}`).toEqual([]); + }); + + it('the app authors no agents of its own — the surface is skills-only', () => { + // ADR-0063 §2. Re-introducing an app agent is what makes a name like + // `sales_copilot` look plausible in `defaultAgent` again; skills are the + // supported way to deepen an app's AI capability. + const agents: AnyRec[] = (stack as any).agents ?? []; + expect( + agents.map((a) => a.name), + 'app-authored agents were retired in #512 — author skills instead (ADR-0063 §2)', + ).toEqual([]); + expect(((stack as any).skills ?? []).length, 'no skills registered').toBeGreaterThan(0); + }); +});