SIGN IN SIGN UP

feat(ai-chat): address field references by name (#24701)

## Root cause

The metadata references are addressed differently, and only one of the
two addresses can be written before the thing exists.

| | address | who generates it | writable before creation |
|---|---|---|---|
| object | `nameSingular` | the model | yes |
| field | `fieldMetadataId` | the database, at creation | no |

#23573 chose this deliberately and said so: the object marker *"is
name-keyed rather than id-keyed so an object the assistant only proposes
to create still renders as a chip"*, while *"field and view ids must
come from a tool"*. That was correct at the time — the only thing the
assistant ever proposed was an object.

#24331 then added the workspace-setup prompt three weeks later, whose
data-model step asks for *"the custom fields to add"* — fields that by
definition have no id. That created a case the addressing scheme was
never designed for. With no legal `[[field:…]]` available, the model
improvised the symmetric name-addressed form, which matched no pattern,
fell through `findChatReferences` and reached the user as raw brackets.

## Fix

**Address fields by name, the way objects are.**
`[[field:objectNameSingular:fieldName:displayName]]` is now the field
reference the prompt writes.

The routing already pointed this way: the field settings page is
`objects/:objectNamePlural/:fieldName`, so `FieldMetadataLink` was
resolving the id back into those two names just to build the link. The
id was a detour. `FieldMetadataLink` now accepts either address and
falls back to a static chip when the field is not there yet, exactly
like `ObjectMetadataLink`.

The id pattern stays as a reader so the messages already stored with it
keep their chips; the prompt no longer emits it, so it ages out.

The `field` kind now carries the two names, and the id one is
`legacyFieldById` — named for how the field is addressed, not for a
lifecycle state. A name-addressed reference to a field that *does* exist
resolves and links, which is what keeps an onboarding thread navigable
when it is reread after the build.

**Views keep their id, on purpose.** They are the one kind where
id-keying is structural rather than incidental: `view.name` has no
unique constraint, so two views on one object can share a name, and the
destination carries the id in the query string
(`/objects/companies?viewId=…`) rather than resolving to names. So a
proposed view has the same latent gap, but nothing to address it by. In
practice the onboarding flow never proposes one — *"New fields land
visible on their object's index view, so no view work is needed"* — and
the fallback below keeps it from ever looking like this bug did.

**Degrade anything unrecognized to plain text.**
`replaceMalformedChatReferencesWithDisplayName` collapses any
`[[record|object|field|view:…]]` marker no pattern accepts down to its
display name. It runs only on the slices *between* valid references in
`getChatReferenceSegments`, so a valid reference next to a broken one is
untouched, and it leaves a marker carrying no display name alone rather
than surfacing a bare UUID as prose.

This is a deliberate, narrow step back from #23852, which removed the
old compensation machinery on the grounds that the prompt would keep
model output well-formed. That held for the cosmetic cases it listed; it
did not survive a step where the prompt asked for something the syntax
could not express. It does not *repair* a marker into a chip the way the
deleted code did — it only strips one that cannot be parsed, so the
surplus-bracket and unclosed-marker behaviour #23852 settled on is
unchanged.

`protectChatReferencesForMarkdown` returned the original `text` on its
single-string-segment fast path, which would have handed back the raw
brackets; it now returns the segment.

## Test

- `ChatReferenceChip.stories.tsx` — a `ProposedField` story covering
both halves: a field that does not exist renders as a chip with no
`href`, an existing field addressed by name links to its settings page.
Verified in Chromium.
- `replaceMalformedChatReferencesWithDisplayName.test.ts` — every kind,
unclosed markers, unknown prefixes, missing display name
- `protectChatReferencesForMarkdown.test.ts` — name-addressed references
survive the markdown round-trip; an unparseable marker collapses next to
a valid one
- `TextWithChatReferences.test.tsx` — both field addresses route to the
field chip
- `workspace-setup-system-prompt.constant.spec.ts` — the prompt clauses

267 front + 167 server tests pass; both packages lint, format and
typecheck clean.
E
Etienne committed
e966b5d526f0550a4ab15a90476459e28987c897
Parent: aee0c31
Committed by GitHub <noreply@github.com> on 8/25/2026, 12:13:49 PM