Skip to content

Commit 2f979b4

Browse files
authored
fix: normalize framework identity to fix dead prompt guidance (#486)
1 parent 12ba782 commit 2f979b4

23 files changed

Lines changed: 517 additions & 100 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@frontman/frontman-server-assets": patch
3+
"@frontman-ai/frontman-core": patch
4+
"@frontman-ai/nextjs": patch
5+
"@frontman-ai/vite": patch
6+
"@frontman-ai/astro": patch
7+
"@frontman-ai/client": patch
8+
---
9+
10+
Fix framework-specific prompt guidance never being applied in production. The middleware sent display labels like "Next.js" but the server matched on "nextjs", so 120+ lines of Next.js expert guidance were silently skipped. Introduces a `Framework` module as single source of truth for framework identity, normalizes at the server boundary, and updates client adapters to send normalized IDs.

apps/frontman_server/lib/frontman_server/tasks/execution.ex

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule FrontmanServer.Tasks.Execution do
2222
alias FrontmanServer.Providers
2323
alias FrontmanServer.Providers.ResolvedKey
2424
alias FrontmanServer.Tasks
25-
alias FrontmanServer.Tasks.Execution.{RootAgent, ToolExecutor}
25+
alias FrontmanServer.Tasks.Execution.{Framework, RootAgent, ToolExecutor}
2626
alias FrontmanServer.Tasks.{Interaction, Task}
2727
alias SwarmAi.LLM.Chunk
2828
alias SwarmAi.Message
@@ -214,7 +214,8 @@ defmodule FrontmanServer.Tasks.Execution do
214214
defp build_agent(%Task{} = task, tools, opts, %ResolvedKey{} = resolved_key) do
215215
case Keyword.get(opts, :agent) do
216216
nil ->
217-
has_typescript_react = task.framework in ["nextjs"]
217+
fw = Framework.from_string(task.framework)
218+
has_typescript_react = Framework.has_typescript_react?(fw)
218219

219220
# Derive prompt data from task interactions
220221
project_rules =
@@ -245,7 +246,7 @@ defmodule FrontmanServer.Tasks.Execution do
245246
has_annotations: Interaction.has_annotations?(task.interactions),
246247
has_current_page: Interaction.has_current_page?(task.interactions),
247248
has_typescript_react: has_typescript_react,
248-
framework: task.framework,
249+
framework: fw,
249250
model: model_spec,
250251
llm_opts: llm_opts,
251252
project_rules: project_rules,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
defmodule FrontmanServer.Tasks.Execution.Framework do
2+
@moduledoc """
3+
Single source of truth for framework identity.
4+
5+
Framework adapters (Next.js, Vite, Astro) each send a normalized ID on the
6+
client which flows through ACP into the server. This module canonicalizes
7+
those IDs into typed structs used for DB storage, prompt building, and
8+
feature-flag derivation.
9+
10+
Unrecognized frameworks crash immediately — if we receive a value that isn't
11+
one of our known adapters, that's a bug in the adapter or a missing server
12+
mapping and we want to know about it loudly.
13+
14+
## Usage
15+
16+
iex> Framework.from_client_label("nextjs")
17+
%Framework{id: :nextjs, display_name: "Next.js"}
18+
19+
iex> Framework.to_string(%Framework{id: :nextjs, display_name: "Next.js"})
20+
"nextjs"
21+
22+
iex> Framework.has_typescript_react?(%Framework{id: :nextjs, display_name: "Next.js"})
23+
true
24+
"""
25+
26+
use TypedStruct
27+
28+
@type id :: :nextjs | :vite | :astro
29+
30+
typedstruct enforce: true do
31+
@typedoc "Framework identity with display metadata"
32+
field(:id, id())
33+
field(:display_name, String.t())
34+
end
35+
36+
# ── Constructors (private, build structs after typedstruct defines them) ──
37+
38+
defp build(:nextjs), do: %__MODULE__{id: :nextjs, display_name: "Next.js"}
39+
defp build(:vite), do: %__MODULE__{id: :vite, display_name: "Vite"}
40+
defp build(:astro), do: %__MODULE__{id: :astro, display_name: "Astro"}
41+
42+
# ── Public API ────────────────────────────────────────────────────────
43+
44+
@doc """
45+
All known framework ids.
46+
"""
47+
@spec known_ids() :: [id()]
48+
def known_ids, do: [:nextjs, :vite, :astro]
49+
50+
@doc """
51+
Normalize a raw client string into a framework struct.
52+
53+
Handles two forms:
54+
1. Normalized IDs (current clients): `"nextjs"`, `"vite"`, `"astro"`
55+
2. Legacy display labels: `"Next.js"`, `"Vite"`, `"Astro"`
56+
57+
Raises on unrecognized input — if we get a framework value we don't know
58+
about, that's a bug that needs fixing, not silently swallowing.
59+
60+
iex> Framework.from_client_label("nextjs")
61+
%Framework{id: :nextjs, display_name: "Next.js"}
62+
63+
iex> Framework.from_client_label("Next.js")
64+
%Framework{id: :nextjs, display_name: "Next.js"}
65+
66+
iex> Framework.from_client_label("VITE")
67+
%Framework{id: :vite, display_name: "Vite"}
68+
"""
69+
@spec from_client_label(String.t()) :: t()
70+
def from_client_label(label) when is_binary(label) do
71+
case display_label_to_id(label) do
72+
nil -> label |> normalize_raw() |> slug_to_id() |> build()
73+
id -> build(id)
74+
end
75+
end
76+
77+
@doc """
78+
Build a framework struct from a DB-stored string identifier.
79+
80+
The DB stores normalized strings like `"nextjs"`. Raises on unrecognized
81+
values — if the DB contains garbage, that's a data integrity issue.
82+
83+
iex> Framework.from_string("nextjs")
84+
%Framework{id: :nextjs, display_name: "Next.js"}
85+
"""
86+
@spec from_string(String.t()) :: t()
87+
def from_string("nextjs"), do: build(:nextjs)
88+
def from_string("vite"), do: build(:vite)
89+
def from_string("astro"), do: build(:astro)
90+
91+
@doc """
92+
Serialize a framework struct to the string stored in the database.
93+
94+
iex> fw = Framework.from_string("nextjs")
95+
iex> Framework.to_string(fw)
96+
"nextjs"
97+
"""
98+
@spec to_string(t()) :: String.t()
99+
def to_string(%__MODULE__{id: :nextjs}), do: "nextjs"
100+
def to_string(%__MODULE__{id: :vite}), do: "vite"
101+
def to_string(%__MODULE__{id: :astro}), do: "astro"
102+
103+
# ── Feature flags ─────────────────────────────────────────────────────
104+
105+
@doc """
106+
Whether the framework implies TypeScript + React tooling.
107+
108+
Currently only Next.js.
109+
110+
iex> fw = Framework.from_string("nextjs")
111+
iex> Framework.has_typescript_react?(fw)
112+
true
113+
114+
iex> fw = Framework.from_string("vite")
115+
iex> Framework.has_typescript_react?(fw)
116+
false
117+
"""
118+
@spec has_typescript_react?(t()) :: boolean()
119+
def has_typescript_react?(%__MODULE__{id: :nextjs}), do: true
120+
def has_typescript_react?(%__MODULE__{}), do: false
121+
122+
# ── Internals ─────────────────────────────────────────────────────────
123+
124+
# Exact display-label → id (case-sensitive, fast path for legacy clients)
125+
defp display_label_to_id("Next.js"), do: :nextjs
126+
defp display_label_to_id("Vite"), do: :vite
127+
defp display_label_to_id("Astro"), do: :astro
128+
defp display_label_to_id(_), do: nil
129+
130+
# Defensive normalization: lowercase, strip non-alpha, then match.
131+
defp normalize_raw(label) do
132+
label
133+
|> String.downcase()
134+
|> String.replace(~r/[^a-z]/, "")
135+
end
136+
137+
# Match the cleaned slug to a known id. No catch-all — crash on unknown.
138+
defp slug_to_id("nextjs"), do: :nextjs
139+
defp slug_to_id("vite"), do: :vite
140+
defp slug_to_id("astro"), do: :astro
141+
end

apps/frontman_server/lib/frontman_server/tasks/execution/prompts.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ defmodule FrontmanServer.Tasks.Execution.Prompts do
66
- Root agent (dynamic, context-aware)
77
"""
88

9+
alias FrontmanServer.Tasks.Execution.Framework
10+
911
# --- Root Agent Prompts ---
1012

1113
@base_tool_selection_guidance """
@@ -169,8 +171,12 @@ defmodule FrontmanServer.Tasks.Execution.Prompts do
169171
defp maybe_append(prompt, true, guidance_fn), do: prompt <> "\n" <> guidance_fn.()
170172
defp maybe_append(prompt, false, _guidance_fn), do: prompt
171173

172-
defp append_framework_guidance(prompt, "nextjs"), do: prompt <> "\n" <> nextjs_guidance()
173-
defp append_framework_guidance(prompt, _), do: prompt
174+
defp append_framework_guidance(prompt, %Framework{id: :nextjs}),
175+
do: prompt <> "\n" <> nextjs_guidance()
176+
177+
defp append_framework_guidance(prompt, %Framework{id: :vite}), do: prompt
178+
defp append_framework_guidance(prompt, %Framework{id: :astro}), do: prompt
179+
defp append_framework_guidance(prompt, nil), do: prompt
174180

175181
defp append_project_structure(prompt, nil), do: prompt
176182
defp append_project_structure(prompt, ""), do: prompt

apps/frontman_server/lib/frontman_server/tasks/execution/root_agent.ex

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ defmodule FrontmanServer.Tasks.Execution.RootAgent do
1616

1717
use TypedStruct
1818

19+
alias FrontmanServer.Tasks.Execution.Framework
1920
alias FrontmanServer.Tasks.Execution.{LLMClient, Prompts}
2021

2122
typedstruct do
2223
field(:tools, [SwarmAi.Tool.t()], default: [])
2324
field(:has_annotations, boolean(), default: false)
2425
field(:has_current_page, boolean(), default: false)
2526
field(:has_typescript_react, boolean(), default: false)
26-
field(:framework, String.t() | nil, default: nil)
27+
field(:framework, Framework.t() | nil, default: nil)
2728
# llm_opts must include :api_key (resolved at domain layer)
2829
# May also include :requires_mcp_prefix and :identity_override for OAuth
2930
field(:llm_opts, keyword(), default: [])
@@ -42,7 +43,7 @@ defmodule FrontmanServer.Tasks.Execution.RootAgent do
4243
- `:tools` - List of SwarmAi.Tool structs available to the agent
4344
- `:has_annotations` - Whether the user has annotated elements in the UI
4445
- `:has_current_page` - Whether current page context is available
45-
- `:framework` - Framework name (e.g., "nextjs") for framework-specific guidance
46+
- `:framework` - `Framework.t()` struct for framework-specific guidance
4647
- `:llm_opts` - LLM options, must include `:api_key`. May include `:requires_mcp_prefix`
4748
and `:identity_override` for OAuth transformations (handled by LLMClient).
4849
- `:model` - LLM model spec (defaults to LLMClient default)

apps/frontman_server/lib/frontman_server_web/channels/tasks_channel.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule FrontmanServerWeb.TasksChannel do
1212

1313
alias AgentClientProtocol, as: ACP
1414
alias FrontmanServer.Tasks
15+
alias FrontmanServer.Tasks.Execution.Framework
1516
alias FrontmanServer.Tasks.TitleGenerator
1617
alias FrontmanServerWeb.ACPHistory
1718

@@ -113,8 +114,15 @@ defmodule FrontmanServerWeb.TasksChannel do
113114
Logger.info("ACP session/new request received with sessionId: #{session_id}")
114115

115116
with :ok <- validate_uuid_format(session_id),
116-
framework when framework != nil <- extract_framework(socket.assigns[:acp_client_info]),
117-
{:ok, ^session_id} <- Tasks.create_task(socket.assigns.scope, session_id, framework) do
117+
raw_framework when is_binary(raw_framework) <-
118+
extract_framework(socket.assigns[:acp_client_info]),
119+
fw = Framework.from_client_label(raw_framework),
120+
{:ok, ^session_id} <-
121+
Tasks.create_task(
122+
socket.assigns.scope,
123+
session_id,
124+
Framework.to_string(fw)
125+
) do
118126
push_response(socket, id, ACP.build_session_new_result(session_id))
119127
else
120128
:error ->
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
defmodule FrontmanServer.Repo.Migrations.NormalizeFrameworks do
2+
@moduledoc """
3+
Normalize framework display labels to internal identifiers.
4+
5+
The framework middleware used to send display labels like "Next.js" which were
6+
stored directly in the database. Now we normalize to lowercase identifiers
7+
("nextjs", "vite", "astro") at the channel ingestion boundary, and this
8+
migration cleans up existing rows.
9+
"""
10+
use Ecto.Migration
11+
12+
def up do
13+
# Normalize known display labels to IDs
14+
execute("UPDATE tasks SET framework = 'nextjs' WHERE framework = 'Next.js'")
15+
execute("UPDATE tasks SET framework = 'vite' WHERE framework = 'Vite'")
16+
execute("UPDATE tasks SET framework = 'astro' WHERE framework = 'Astro'")
17+
18+
# Verify no unknown frameworks remain — crash the migration if so.
19+
# If this fails, investigate what unexpected values exist before proceeding.
20+
execute("""
21+
DO $$
22+
BEGIN
23+
IF EXISTS (
24+
SELECT 1 FROM tasks
25+
WHERE framework NOT IN ('nextjs', 'vite', 'astro')
26+
OR framework IS NULL
27+
) THEN
28+
RAISE EXCEPTION 'Found tasks with unrecognized framework values. '
29+
'Run: SELECT DISTINCT framework FROM tasks WHERE framework NOT IN (''nextjs'', ''vite'', ''astro'') '
30+
'to investigate before migrating.';
31+
END IF;
32+
END $$;
33+
""")
34+
end
35+
36+
def down do
37+
execute("UPDATE tasks SET framework = 'Next.js' WHERE framework = 'nextjs'")
38+
execute("UPDATE tasks SET framework = 'Vite' WHERE framework = 'vite'")
39+
execute("UPDATE tasks SET framework = 'Astro' WHERE framework = 'astro'")
40+
end
41+
end

0 commit comments

Comments
 (0)