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
17 changes: 17 additions & 0 deletions .changeset/app-default-agent-platform-binding.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion src/apps/crm.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions src/pages/account_detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
67 changes: 67 additions & 0 deletions test/metadata-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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);
});
});
Loading