Skip to content
Closed
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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
ssr_boot_apps: ${{ steps.scope.outputs.ssr_boot_apps }}
guards: ${{ steps.scope.outputs.guards }}
qa_static: ${{ steps.scope.outputs.qa_static }}
starter_patch: ${{ steps.scope.outputs.starter_patch }}
neon_query_budget: ${{ steps.scope.outputs.neon_query_budget }}
query_budget_matrix: ${{ steps.scope.outputs.query_budget_matrix }}
neon_connection_budget: ${{ steps.scope.outputs.neon_connection_budget }}
Expand Down Expand Up @@ -1281,6 +1282,26 @@ jobs:
if: ${{ !cancelled() && needs.change-scope.outputs.qa_static == 'true' }}
run: pnpm actions:audit

# The starter repo applies starters/fusion to every mirrored Chat
# template; a templates/chat or core rewrite that drops a snippet it
# replaces must fail here, in the PR that caused it.
- name: Fusion starter patch applies to the Chat template
if: ${{ !cancelled() && needs.change-scope.outputs.starter_patch == 'true' }}
run: |
set -euo pipefail
tree="$(mktemp -d)/template"
pnpm exec tsx packages/core/src/cli/index.ts template materialize --template chat --out "$tree"
if ! node starters/fusion/apply.ts --root "$tree"; then
echo "::error::The Fusion starter patch no longer applies to templates/chat. Update starters/fusion in this PR; see starters/fusion/README.md."
exit 1
fi
git -C "$tree" init -q
git -C "$tree" add -A
git -C "$tree" -c user.name=ci -c user.email=ci@users.noreply.github.com commit -qm patched
node starters/fusion/apply.ts --root "$tree"
git -C "$tree" diff --exit-code
test -z "$(git -C "$tree" status --porcelain)"

standalone-chat:
name: Generate + run standalone Chat
needs: change-scope
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/push-starter-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "templates/chat/**"
- "starters/fusion/**"
- ".github/workflows/push-starter-template.yml"
repository_dispatch:
types: [agent-native-packages-published]
Expand Down Expand Up @@ -141,6 +142,11 @@ jobs:
--exclude '.git/' \
--exclude '.github/' \
./dist/template/ /tmp/starter/
# The starter patch lives in starters/fusion so CI here catches
# template changes that break it; the starter's sync.yml applies it
# from .github/starter-patch on the template branch.
mkdir -p /tmp/starter/.github/starter-patch
rsync -a --delete ./starters/fusion/ /tmp/starter/.github/starter-patch/
cd /tmp/starter
git config user.name "builder-bot"
git config user.email "noreply@builder.io"
Expand Down
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"**/*.e2e-host.tsx",
"packages/core/src/cli/**",
"packages/core/src/templates/**",
"starters/**",
"packages/core/src/provider-api/**",
"templates/plan/**",
"templates/analytics/actions/provider-api-request.ts",
Expand Down
52 changes: 52 additions & 0 deletions scripts/ci-change-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,58 @@ test("fails closed to AgentKit acceptance for unknown and empty scopes", () => {
);
});

test("applies the Fusion starter patch for Chat, core, and patch changes", () => {
for (const path of [
"templates/chat/AGENTS.md",
"templates/chat/.agents/skills/storing-data/SKILL.md",
"templates/chat/package.json",
"packages/core/src/cli/template-sync.ts",
]) {
const scope = classifyChangedPaths([path]);
assert.equal(
scope.checks.starter_patch,
true,
`${path} must apply the Fusion starter patch`,
);
assert.equal(scope.checks.guards, true, `${path} must run Security guards`);
}

assert.equal(
classifyChangedPaths(["templates/calendar/app/routes/index.tsx"]).checks
.starter_patch,
false,
);
assert.equal(
classifyChangedPaths(["packages/dispatch/src/index.ts"]).checks
.starter_patch,
false,
);
});

test("keeps a Fusion starter patch change targeted to its guard step", () => {
const scope = classifyChangedPaths([
"starters/fusion/apply.ts",
"starters/fusion/overlay/app/routes/_index.tsx",
]);

assert.equal(scope.full, false);
assert.deepEqual(
Object.entries(scope.checks)
.filter(([, enabled]) => enabled)
.map(([name]) => name),
["lint", "guards", "starter_patch"],
);
});

test("applies the Fusion starter patch on full and tooling-only runs", () => {
assert.equal(classifyChangedPaths([]).checks.starter_patch, true);
assert.equal(
classifyChangedPaths([".github/workflows/push-starter-template.yml"]).checks
.starter_patch,
true,
);
});

test("keeps package metadata targeted but runs the guards that scan it", () => {
const scope = classifyChangedPaths(["templates/calendar/package.json"]);

Expand Down
16 changes: 14 additions & 2 deletions scripts/ci-change-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const DOCS_SUPPORT_PATHS = new Set([
]);

const COMMUNITY_TEMPLATES_ROOT = "community-templates";
// The Fusion starter patch is not a workspace package; it is applied to the
// materialized Chat template, so only Chat, core, and the patch can break it.
const STARTER_PATCH_ROOT = "starters/fusion/";

// Agent instructions are inputs to core (bundled skills, prompt resources) and
// to the skills package, and several guards read them, so they are not docs.
Expand Down Expand Up @@ -127,6 +130,7 @@ const CHECK_NAMES = [
"ssr_boot",
"guards",
"qa_static",
"starter_patch",
"agentkit_acceptance",
"neon_query_budget",
"neon_connection_budget",
Expand Down Expand Up @@ -403,6 +407,7 @@ function isFullPath(path: string): boolean {
if (normalized.startsWith("scripts/") && !isDocsPath(normalized)) {
return true;
}
if (normalized.startsWith(STARTER_PATCH_ROOT)) return false;

return !isWorkspacePath(normalized) && !isDocsPath(normalized);
}
Expand Down Expand Up @@ -566,6 +571,7 @@ function buildChecks(
"packages/shared-app-config/",
);
const chatChanged = hasPath(changedPaths, "templates/chat/");
const starterPatchChanged = hasPath(changedPaths, STARTER_PATCH_ROOT);
const schedulingChanged = hasPath(changedPaths, "packages/scheduling/");
const dispatchChanged = hasPath(changedPaths, "packages/dispatch/");
const contentChanged = hasPath(changedPaths, "templates/content/");
Expand Down Expand Up @@ -594,7 +600,11 @@ function buildChecks(
changedPaths.some(isContentConvergenceRuntimePath) || coreChanged;

return {
lint: workspaceChanged || instructionsChanged || guardScriptsChanged,
lint:
workspaceChanged ||
instructionsChanged ||
guardScriptsChanged ||
starterPatchChanged,
typecheck: workspaceChanged,
fast_tests: workspaceChanged || instructionsChanged,
content: contentChanged || coreChanged || schedulingChanged,
Expand Down Expand Up @@ -622,8 +632,10 @@ function buildChecks(
workspaceChanged ||
instructionsChanged ||
guardScriptsChanged ||
readmeChanged,
readmeChanged ||
starterPatchChanged,
qa_static: templateChanged,
starter_patch: chatChanged || coreChanged || starterPatchChanged,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Run the patch compatibility check for Toolkit changes

The generated overlay imports Toolkit APIs such as AppProviders, ErrorBoundary, and HeaderActionsProvider, but this selector enables the compatibility lane only for Chat, core, or patch changes. A Toolkit-only API change can merge without checking those imports against the patched starter; include toolkitChanged in the selector.

Fix in Builder

agentkit_acceptance:
coreChanged ||
toolkitChanged ||
Expand Down
1 change: 1 addition & 0 deletions starters/fusion/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.tmp-*
100 changes: 100 additions & 0 deletions starters/fusion/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Fusion starter patch

Source of truth: `starters/fusion/` in BuilderIO/agent-native. Edit it there, in
the same PR as the `templates/chat` or skill change that needs it.
Agent-Native's `push-starter-template.yml` mirrors the pristine `templates/chat` tree
onto the starter repo's `template` branch and copies this directory to
`.github/starter-patch/` there, replacing whatever was on the branch, so edits
made directly in the starter repo are overwritten by the next mirror.

The starter's `sync.yml` applies this overlay while merging `template` into
`main`, so Fusion clones a thinner English-only canvas without changing the
upstream chat template. Agent-Native CI applies it to the materialized Chat
template on every PR that changes `templates/chat`, `packages/core`, or this
directory, so an upstream rewrite that breaks a replacement fails in that PR.

`sync.yml` archives the pristine `origin/template` tree before the merge. The
patcher restores every path in the append-only `owned.txt` manifest from that
archive before applying the current replacements, overlay, and deletions. This
prevents prior patch output from accumulating or blocking a changed patch.

## What it does

- Replaces the chat-first homepage with a blank **Your app here** canvas
- Ships a clean app shell with no chat or agent rail by default (agent surfaces
are opt-in; the shell still meets the Agent-Native contract)
- De-brands the PWA manifest (`public/manifest.json`): neutralizes the Chat
name/description and repoints `start_url` from the chat `/home` route to the
app canvas root, and deletes the chat marketing screenshot in
`public/auth-marketing/` that the old chat login page referenced
- Ships an impress-by-default `DESIGN.md` and de-brands the design skill so
generated apps look polished even with no design brief
- Replaces the `CLAUDE.md` symlink with a one-line pointer to `AGENTS.md` so
agents that read both don't ingest the guide twice
- Trims agent-authoring skills that are dead weight inside a generated app
(`turn-into-app`, `turn-into-skill`, `workspace-conventions`)
- Disables opt-in default plugins via `server/plugins/config.ts`
(`defineAppConfig({ plugins: { disabled: [...] } })`) so a blank app doesn't
boot Slack/Telegram/etc routes, error tracking, the PTY terminal, or agent
long-term memory (`integrations`, `observational-memory`, `sentry`,
`terminal`). This must live in the app config layer that `getAppConfig()`
reads — `plugins.disabled` in `agent-native.json` is ignored by the
plugin-mount decision.
- Strips i18n catalogs, language pickers, changelog, and What's New
- Adds an `authentication` skill telling agents to wire `server/plugins/auth.ts`
via `createAuthPlugin` with a branded `loginHtml` (using the framework's own
`/_agent-native/auth/{register,login}` endpoints, reloading in place at the
root path or resuming to the app's configured `app.homePath` for gated
non-root routes), `workspaceAppPublicPaths` for ungated routes, and both a
signup control and a logout control (via the `signOut()` client helper). The
skill also documents the framework's `MarketingHome`
(`@agent-native/toolkit/marketing`) shell so agents can put a public
marketing page in front of a gated app when a public/private split is wanted
- Adds Drizzle discovery files without touching private `drizzle/schema.ts`
- Re-adds Fusion-managed Drizzle deps/scripts (`drizzle-orm`, `drizzle-kit`,
`db:generate`, `db:migrate`, `dotenv`, `@neondatabase/serverless`) that the
chat template does not ship, plus the hosted `db:migrate` step in
`netlify.toml`. It also makes generated Drizzle migrations the sole app
migration path and keeps `scripts/migrate-production.ts` framework-only. It
also guards `scripts/migrate-production.ts` so it only runs as its own process
(`pnpm migrate:production`): action auto-discovery mounts the script as a live
route, and running its `finally { closeDbExec() }` in-process would tear down
the shared connection pool and break every later request. The guard throws
instead when the file is imported rather than run directly.
These files are overlay-owned so template syncs cannot restore the conflicting
app migration instructions or remove first-boot `db:migrate`.
- Excludes `@agent-native/*` from pnpm `minimumReleaseAge` so a same-day
framework publish does not fail Fusion `pnpm install`
- Drops `--open` from the `dev` script so headless Fusion cloud environments
don't throw `spawn xdg-open ENOENT` trying to auto-open a browser
- Sets `onboarding: { firstRun: "off" }` in `agent-native.config.ts` so
generated apps skip the framework's first-run connect/integrations setup —
Fusion already provisions connections for these projects
- Keeps generated app actions in the first-turn tool catalog and distinguishes
domain-object operations from requests that actually require source changes
- Exposes app actions as native tools in local dev (`nativeActionsInDev`) so
Fusion sessions match production tool schemas instead of bash/CLI indirection
- Rejects text-only source-code handoffs for domain requests until the agent
has tried `tool-search` or a matching app action (`app-operation-guard`)
- Tells agents enabling auth to inspect the generated product routes, preserve a
valid existing `app.homePath`, or select the real primary authenticated route
and persist it in `server/plugins/config.ts` without assuming `/home`. The
blank starter ships `homePath: "/"` so sign-in lands on the canvas instead of
Chat's missing `/home`; agents replace it once product routes exist.
- Replaces the chat template's `README.md` with a generic "My App" README and
tells agents (in `DEVELOPING.md`) to rewrite it once the app has real features
- Tells agents to typecheck once per batch and skip i18n/changelog unless asked

## Apply

```bash
node starters/fusion/apply.ts --root /path/to/tree
```

In the starter repo the same script runs as `.github/starter-patch/apply.ts`.

The sync workflow additionally passes `--source-root /path/to/pristine-template`
to migrate a previously patched `main` tree to the current patch definition.

The script fails if an expected snippet is missing (upstream copy drifted) or
if a stripped path is still present after apply.
72 changes: 72 additions & 0 deletions starters/fusion/VERIFICATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Verifying the Fusion overlay

## Automated

Agent-Native's `Security guards` job materializes `templates/chat`, applies this
overlay, then applies it again and requires a clean working tree, on every PR
that changes `templates/chat`, `packages/core`, or `starters/fusion`. In the
starter repo, `.github/workflows/verify-starter-patch.yml` also applies it to
the currently patched `main` tree. Both assert:

- Homepage is a `return (\n <div` canvas containing "Your app here"
- Layout is a clean canvas shell (`agent-native-app-main`) with no default agent
rail or chat
- Changelog and i18n catalogs are gone
- Drizzle discovery files exist
- `public/manifest.json` is de-branded (no `Chat` identity, `start_url` no
longer deep-links to `/home`) and the chat `public/auth-marketing/` screenshot
is removed
- `package.json` has `db:generate` / `db:migrate` and `drizzle-orm` / `drizzle-kit`
- Hosted build runs `pnpm migrate:production && pnpm db:migrate`
- Storage guidance keeps app migrations in generated Drizzle files
- `scripts/migrate-production.ts` remains framework-only
- `scripts/migrate-production.ts` guards against in-process execution so its
shared-pool teardown only runs as a direct process entrypoint
- `dev` script is `agent-native dev` (no `--open`) so headless cloud boots don't
fail on `xdg-open`
- `pnpm-workspace.yaml` excludes `@agent-native/*` from `minimumReleaseAge`
- `agent-native.config.ts` sets `onboarding: { firstRun: "off" }`
- Agent chat derives first-turn tools from app-owned actions and tells the model
to search for an app action before handing an ambiguous request to Builder
- Generated `AGENTS.md` requires route inspection before auth, persists the
selected landing route as `app.homePath`, and explicitly forbids assuming
`/home` when that route does not exist
- The blank `server/plugins/config.ts` retains `plugins.disabled` and lands
sign-in on the canvas (`homePath: "/"`) until product routes exist
- `README.md` is the generic "My App" README with no chat template copy, and
`DEVELOPING.md` tells agents to rewrite it
- Previously patched output migrates to the current overlay without duplicates
- A second application produces no changes

## After merge to `template`

The next push to `template` runs `sync.yml`, which merges into `main`, applies
this overlay, then typechecks and builds. Failures stay off `main`.

## Auth landing route (manual)

In representative generated projects, enable auth and verify signup and login
land on an existing page rather than a 404:

- An app whose primary product route is `/` selects `/`
- An app with a public `/` and private `/dashboard` selects `/dashboard`
- An existing valid `app.homePath` is preserved
- `/home` is not selected when that route is absent
- Equally plausible private landing routes cause the agent to ask the user
rather than inventing a choice

Confirm the selected value is merged into the existing `defineAppConfig` object
in `server/plugins/config.ts` and its `plugins.disabled` list remains intact.

## Fusion first-prompt comparison (manual, ENG-13611)

After `main` has a patched revision:

1. Create two Fusion projects from `agent-native-starter` (before SHA vs after)
2. Use the same first prompt as the ENG-13611 React-vs-agent-native evidence
3. Record time-to-first-edit, total completion, discovery tool calls, and
typecheck count
4. Confirm Fusion still injects `FUSION_GENERATION_APP_PLACEHOLDER` into
`app/routes/_index.tsx`

This repo cannot run that comparison from CI; attach numbers on ENG-13611.
Loading
Loading