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
12 changes: 9 additions & 3 deletions backend/erato.template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -456,13 +456,19 @@ chat_provider_ids = ["test-token-limit"]
# display_name = "Outlook Appointment Scheduling"
# platform = "outlook"
# tool_call_allowlist = ["outlook/*"]
# # Do not declare client_actions here until the add-in implements the action
# # (ERMAIN-387): the model would announce a confirmation the UI never shows.
# # Confirm path: once the user picks a slot, the model emits the appointment
# # as an erato-appointment fenced JSON block and proposes this action; the
# # add-in confirms and opens a prefilled appointment form (nothing is created
# # server-side). The three keys travel together (startup validation).
# client_actions = ["outlook.create_appointment"]
# presentation = "auto_prompt"
# client_actions_always_ask = ["outlook.create_appointment"]
# allowed_args = ["now_iso", "timezone"]
# template = """
# FOR THIS MESSAGE ONLY: <your scheduling instructions; have the model call the
# fetch_availability tool before stating any availability, reason over the
# returned data in the user's local time zone, and present valid free slots>
# returned data in the user's local time zone, present valid free slots, and on
# a pick emit one erato-appointment JSON block + one propose_client_action call>
# """

# Experimental facets feature configuration
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/components/ui/Message/MessageContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
THEME_MODE_LOCAL_STORAGE_KEY,
ThemeProvider,
} from "@/components/providers/ThemeProvider";
import { componentRegistry } from "@/config/componentRegistry";
import { messages as enMessages } from "@/locales/en/messages.json";
import { StaticFeatureConfigProvider } from "@/providers/FeatureConfigProvider";
import { FileTypeUtil } from "@/utils/fileTypes";
Expand Down Expand Up @@ -941,6 +942,90 @@ describe("MessageContent", () => {
expect(screen.getByRole("button", { name: /Copy/ })).toBeInTheDocument();
});

it("does NOT whole-body-fallback for an action facet without a bodyFormat", () => {
renderWithTheme(
<MessageContent
content={textContent(
"Tuesday 10:00 works well; Wednesday is fully booked.",
)}
outlookArtifact={{
facetId: "outlook_schedule",
renderMode: "body",
allowedClientActions: ["outlook.create_appointment"],
proposedClientAction: "outlook.create_appointment",
}}
/>,
);

// Scheduling prose is prose — no bodyFormat means no insertable email,
// even though the facet proposed a (non-email) client action.
expect(screen.queryByRole("button", { name: /Copy/ })).toBeNull();
expect(screen.getByText(/Tuesday 10:00 works well/).tagName).toBe("P");
});

it("does NOT rescue drifted email tags for an action facet without a bodyFormat", () => {
const { container } = renderWithTheme(
<MessageContent
content={textContent("```text\nsome quoted schedule data\n```")}
outlookArtifact={{
facetId: "outlook_schedule",
renderMode: "body",
allowedClientActions: ["outlook.create_appointment"],
}}
/>,
);

// A generic fence in scheduling prose stays an ordinary code block.
expect(
container.querySelector("pre.message-content-code-block code"),
).toHaveTextContent("some quoted schedule data");
});

it("dispatches erato-appointment fences to a registered renderer", () => {
const original = componentRegistry.EratoAppointmentCodeBlock;
function AppointmentBlockStub({ content }: { content: string }) {
return <div data-testid="appointment-block">{content}</div>;
}
componentRegistry.EratoAppointmentCodeBlock = AppointmentBlockStub;
try {
const { container } = renderWithTheme(
<MessageContent
content={textContent(
'```erato-appointment\n{"start":"2026-07-09T10:00:00+02:00"}\n```',
)}
outlookArtifact={{
facetId: "outlook_schedule",
renderMode: "body",
allowedClientActions: ["outlook.create_appointment"],
}}
/>,
);

expect(screen.getByTestId("appointment-block")).toHaveTextContent(
'"start":"2026-07-09T10:00:00+02:00"',
);
expect(
container.querySelector("pre.message-content-code-block"),
).toBeNull();
} finally {
componentRegistry.EratoAppointmentCodeBlock = original;
}
});

it("keeps erato-appointment fences as plain code blocks without a registered renderer", () => {
const { container } = renderWithTheme(
<MessageContent
content={textContent(
'```erato-appointment\n{"start":"2026-07-09T10:00:00+02:00"}\n```',
)}
/>,
);

expect(
container.querySelector("pre.message-content-code-block code"),
).toHaveTextContent('"start"');
});

it("does NOT whole-body-fallback for review_draft (feedback stays markdown)", () => {
renderWithTheme(
<MessageContent
Expand Down
45 changes: 37 additions & 8 deletions frontend/src/components/ui/Message/MessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
DEFAULT_LIGHT_CODE_HIGHLIGHT_PRESET,
resolvePrismCodeTheme,
} from "@/config/codeHighlightThemes";
import { componentRegistry } from "@/config/componentRegistry";
import { useOptionalTranslation } from "@/hooks/i18n";
import { useTraceFeature } from "@/providers/FeatureConfigProvider";
import { FileTypeUtil } from "@/utils/fileTypes";
Expand Down Expand Up @@ -112,8 +113,12 @@ function classifyEratoEmailBlock(
if (language === "erato-email-html") {
return { isEmail: true, isHtml: looksLikeHtmlFragment(content) };
}
// Only when we KNOW a facet produced this message do we accept drifted tags.
if (artifact && DRIFTED_EMAIL_TAGS.has(language.toLowerCase())) {
// Only when we KNOW an email-bodied facet produced this message do we accept
// drifted tags. `bodyFormat` presence is what marks the facet email-bodied —
// an action-fence facet (e.g. scheduling) also stamps an artifact, but its
// prose may legitimately contain generic code blocks that must not be
// hijacked into email cards.
if (artifact?.bodyFormat && DRIFTED_EMAIL_TAGS.has(language.toLowerCase())) {
return {
isEmail: true,
isHtml: artifact.bodyFormat === "html" && looksLikeHtmlFragment(content),
Expand Down Expand Up @@ -156,7 +161,19 @@ type MarkdownPreProps = React.ComponentPropsWithoutRef<"pre"> & {
node?: unknown;
};

function isEratoEmailCodeChild(
/**
* The `erato-appointment` fence renders as a card only when a host registered
* a renderer for it (the Outlook add-in); otherwise it stays a plain code
* block, exactly like before the registry key existed.
*/
function isEratoAppointmentLanguage(language: string): boolean {
return (
language === "erato-appointment" &&
componentRegistry.EratoAppointmentCodeBlock !== null
);
}

function isCardCodeChild(
children: React.ReactNode,
artifact: OutlookArtifact | null,
): boolean {
Expand All @@ -167,7 +184,10 @@ function isEratoEmailCodeChild(
const match = /language-([\w-]+)/.exec(cls);
const language = match ? match[1] : "";
// Only isEmail is consumed here; it doesn't depend on content.
return classifyEratoEmailBlock(language, artifact, "").isEmail;
return (
classifyEratoEmailBlock(language, artifact, "").isEmail ||
isEratoAppointmentLanguage(language)
);
}

function MarkdownPre({
Expand Down Expand Up @@ -201,11 +221,11 @@ function MarkdownPre({
.catch(() => {});
}, [codeContent]);

// erato-email blocks render a custom component, not a code block —
// use a plain <div> to avoid inheriting <pre> monospace font and
// horizontal scroll from message-content-code-block styling.
// erato-email / erato-appointment blocks render a custom component, not a
// code block — use a plain <div> to avoid inheriting <pre> monospace font
// and horizontal scroll from message-content-code-block styling.
try {
if (isEratoEmailCodeChild(children, artifact)) {
if (isCardCodeChild(children, artifact)) {
return (
<div>
<BlockCodeContext.Provider value={true}>
Expand Down Expand Up @@ -279,6 +299,11 @@ function MarkdownCode({
);
}

const AppointmentBlock = componentRegistry.EratoAppointmentCodeBlock;
if (isBlockCode && AppointmentBlock && isEratoAppointmentLanguage(language)) {
return <AppointmentBlock content={codeContent} />;
}

if (isBlockCode) {
return (
<SyntaxHighlighter
Expand Down Expand Up @@ -932,6 +957,10 @@ export const MessageContent = memo(function MessageContent({
);
const wholeBodyArtifact =
outlookArtifact?.renderMode === "body" &&
// Only an email-bodied facet (body_format present) has a "whole response
// is one insertable email" fallback; an action-fence facet's unfenced
// prose is just prose.
outlookArtifact.bodyFormat !== undefined &&
!isStreaming &&
!showRaw &&
textForArtifact.trim().length > 0 &&
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/config/componentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export interface EratoEmailCodeBlockProps {
isHtml?: boolean;
}

export interface EratoAppointmentCodeBlockProps {
/**
* The raw text inside the erato-appointment code block: the JSON appointment
* payload the model emitted (may be incomplete while streaming).
*/
content: string;
}

/**
* Props for a host-contributed section rendered inside the unified chat "+"
* add menu (e.g. the Outlook add-in's email-content sources). The shared menu
Expand Down Expand Up @@ -173,6 +181,17 @@ export interface ComponentRegistry {
* Copy button.
*/
EratoEmailCodeBlock: ComponentType<EratoEmailCodeBlockProps> | null;

/**
* Override for rendering `erato-appointment` fenced code blocks (the JSON
* appointment payload a scheduling facet's confirm step emits).
*
* Used by the Office addin to render the appointment summary + confirm
* card that opens a prefilled Outlook appointment form.
*
* When null, `erato-appointment` blocks render as a plain code block.
*/
EratoAppointmentCodeBlock: ComponentType<EratoAppointmentCodeBlockProps> | null;
}

type ComponentRegistryComponent<TKey extends keyof ComponentRegistry> = Exclude<
Expand Down Expand Up @@ -218,6 +237,7 @@ const emptyComponentRegistry = (): ComponentRegistry => ({
ChatMessageRenderer: null,
ChatTopLeftAccessory: null,
EratoEmailCodeBlock: null,
EratoAppointmentCodeBlock: null,
});

const buildComponentRegistry = (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export {
type ComponentKitComponentRegistration,
type ComponentKitRegistration,
type EratoEmailCodeBlockProps,
type EratoAppointmentCodeBlockProps,
type ChatAddMenuExtraContentProps,
} from "@/config/componentRegistry";
export {
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,16 @@ export interface MessageError {
export interface OutlookArtifact {
/** The action facet id that produced this assistant message (free-form). */
facetId: string;
/** Output format the facet requested; drives HTML-vs-text rendering. */
bodyFormat: "text" | "html";
/**
* Output format the facet requested; drives HTML-vs-text rendering. Present
* exactly when the facet's output IS an email body (it carried a
* `body_format` arg) — everything email-shaped keys off it: the drifted
* fence-tag rescue and the unfenced whole-body email card. Absent for
* facets whose output is prose that may carry action fences (e.g. a
* scheduling facet's `erato-appointment` block), which must never be
* treated as an insertable email.
*/
bodyFormat?: "text" | "html";
/**
* How to treat the assistant's output:
* - `"body"`: the whole response is a single insertable email body, so an
Expand Down
2 changes: 1 addition & 1 deletion office-addin/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading