diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 835ef7184..f3ac860b3 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -58,9 +58,9 @@ import { } from "@/lib/canvas/agent-context"; import { canApplyCanvasAgentMutations, - getCanvasAgentFeatureFlags, shouldStreamCanvasAgentShadowOperations, } from "@/lib/canvas/agent-feature-flags"; +import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings"; import { buildPendingCanvasOperation, extractPendingCanvasOperationFromWorkingState, @@ -906,7 +906,7 @@ export async function POST(request: Request) { const initialRequestCanvasAgentContext = normalizeCanvasAgentContext( contextPayload || undefined ); - const canvasAgentFeatures = getCanvasAgentFeatureFlags(); + const canvasAgentFeatures = await getCanvasAgentFeatureFlagsFromSystemSettings(); const canApplyCanvasAgentMutation = canApplyCanvasAgentMutations(canvasAgentFeatures); const currentMessageAttachmentCount = message.parts.filter( diff --git a/app/api/canvas/[id]/agent-operations/route.ts b/app/api/canvas/[id]/agent-operations/route.ts index df07f3952..abfbf953e 100644 --- a/app/api/canvas/[id]/agent-operations/route.ts +++ b/app/api/canvas/[id]/agent-operations/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; -import { getCanvasAgentFeatureFlags } from "@/lib/canvas/agent-feature-flags"; +import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings"; import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations"; import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route"; @@ -14,7 +14,7 @@ export async function POST( userId: session?.user?.id, projectId: id, readBody: () => request.json(), - features: getCanvasAgentFeatureFlags(), + features: await getCanvasAgentFeatureFlagsFromSystemSettings(), applyOperation: applyCanvasOperationPlan, logger: console, }); diff --git a/lib/canvas/agent-feature-flags.ts b/lib/canvas/agent-feature-flags.ts index 5e435df19..869946597 100644 --- a/lib/canvas/agent-feature-flags.ts +++ b/lib/canvas/agent-feature-flags.ts @@ -11,7 +11,29 @@ export type CanvasAgentFeatureFlags = { annotationEditEnabled: boolean; }; +export const CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY = + "canvas_agent_operation_protocol_enabled"; +export const CANVAS_AGENT_WRITEBACK_MODE_KEY = "canvas_agent_writeback_mode"; +export const CANVAS_AGENT_SLOT_ENABLED_KEY = "canvas_agent_slot_enabled"; +export const CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY = + "canvas_agent_view_operation_enabled"; +export const CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY = + "canvas_agent_annotation_edit_enabled"; + +export const CANVAS_AGENT_FEATURE_SETTING_KEYS = [ + CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY, + CANVAS_AGENT_WRITEBACK_MODE_KEY, + CANVAS_AGENT_SLOT_ENABLED_KEY, + CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY, + CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY, +] as const; + type EnvLike = Record; +export type CanvasAgentFeatureSettingKey = + (typeof CANVAS_AGENT_FEATURE_SETTING_KEYS)[number]; +export type CanvasAgentFeatureSettingsLike = Partial< + Record +>; const WRITEBACK_MODES = new Set([ "legacy_canvas_media", @@ -23,6 +45,8 @@ export function normalizeCanvasAgentBoolean( value: unknown, fallback: boolean ) { + if (typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value !== 0; if (typeof value !== "string") return fallback; const normalized = value.trim().toLowerCase(); if (!normalized) return fallback; @@ -41,10 +65,41 @@ export function normalizeCanvasAgentWritebackMode( : fallback; } -export function getCanvasAgentFeatureFlags( - env: EnvLike = process.env +export function mergeCanvasAgentFeatureFlagsWithSettings( + flags: CanvasAgentFeatureFlags, + settings?: CanvasAgentFeatureSettingsLike | null ): CanvasAgentFeatureFlags { + if (!settings) return flags; + return { + operationProtocolEnabled: normalizeCanvasAgentBoolean( + settings[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY], + flags.operationProtocolEnabled + ), + writebackMode: normalizeCanvasAgentWritebackMode( + settings[CANVAS_AGENT_WRITEBACK_MODE_KEY], + flags.writebackMode + ), + slotEnabled: normalizeCanvasAgentBoolean( + settings[CANVAS_AGENT_SLOT_ENABLED_KEY], + flags.slotEnabled + ), + viewOperationEnabled: normalizeCanvasAgentBoolean( + settings[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY], + flags.viewOperationEnabled + ), + annotationEditEnabled: normalizeCanvasAgentBoolean( + settings[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY], + flags.annotationEditEnabled + ), + }; +} + +export function getCanvasAgentFeatureFlags( + env: EnvLike = process.env, + settings?: CanvasAgentFeatureSettingsLike | null +): CanvasAgentFeatureFlags { + const envFlags: CanvasAgentFeatureFlags = { operationProtocolEnabled: normalizeCanvasAgentBoolean( env.CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED, true @@ -66,6 +121,7 @@ export function getCanvasAgentFeatureFlags( true ), }; + return mergeCanvasAgentFeatureFlagsWithSettings(envFlags, settings); } export function canApplyCanvasAgentMutations(flags: CanvasAgentFeatureFlags) { diff --git a/lib/canvas/agent-feature-settings.ts b/lib/canvas/agent-feature-settings.ts new file mode 100644 index 000000000..408df756c --- /dev/null +++ b/lib/canvas/agent-feature-settings.ts @@ -0,0 +1,44 @@ +import { inArray } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { systemSettings } from "@/lib/db/schema"; +import { + CANVAS_AGENT_FEATURE_SETTING_KEYS, + type CanvasAgentFeatureFlags, + type CanvasAgentFeatureSettingsLike, + getCanvasAgentFeatureFlags, +} from "@/lib/canvas/agent-feature-flags"; + +type EnvLike = Record; + +export async function readCanvasAgentFeatureSettings(): Promise { + const rows = await db + .select({ key: systemSettings.key, value: systemSettings.value }) + .from(systemSettings) + .where(inArray(systemSettings.key, [...CANVAS_AGENT_FEATURE_SETTING_KEYS])); + + const settings: CanvasAgentFeatureSettingsLike = {}; + rows.forEach((row) => { + if ( + CANVAS_AGENT_FEATURE_SETTING_KEYS.includes( + row.key as (typeof CANVAS_AGENT_FEATURE_SETTING_KEYS)[number] + ) + ) { + settings[row.key as keyof CanvasAgentFeatureSettingsLike] = row.value; + } + }); + return settings; +} + +export async function getCanvasAgentFeatureFlagsFromSystemSettings( + env: EnvLike = process.env +): Promise { + try { + return getCanvasAgentFeatureFlags(env, await readCanvasAgentFeatureSettings()); + } catch (error) { + console.warn( + "[CanvasAgentFeatureFlags] Failed to read SystemSettings, using env fallback:", + error + ); + return getCanvasAgentFeatureFlags(env); + } +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 25304fb66..00ede5be0 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -129,6 +129,11 @@ import { findCanvasAgentOperationAudit, } from "@/lib/canvas/agent-operation-audit"; import { + CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY, + CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY, + CANVAS_AGENT_SLOT_ENABLED_KEY, + CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY, + CANVAS_AGENT_WRITEBACK_MODE_KEY, canApplyCanvasAgentMutations, getCanvasAgentFeatureFlags, normalizeCanvasAgentBoolean, @@ -1805,6 +1810,37 @@ test.describe("canvas chat pure helpers", () => { }); expect(canApplyCanvasAgentMutations(disabledProtocol)).toBe(false); expect(shouldStreamCanvasAgentShadowOperations(disabledProtocol)).toBe(false); + + const settingsOverride = getCanvasAgentFeatureFlags( + { + CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled", + CANVAS_AGENT_WRITEBACK_MODE: "legacy_canvas_media", + CANVAS_AGENT_SLOT_ENABLED: "0", + }, + { + [CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: true, + [CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_authoritative", + [CANVAS_AGENT_SLOT_ENABLED_KEY]: true, + [CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: false, + [CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: "off", + } + ); + expect(settingsOverride).toMatchObject({ + operationProtocolEnabled: true, + writebackMode: "operation_authoritative", + slotEnabled: true, + viewOperationEnabled: false, + annotationEditEnabled: false, + }); + expect(canApplyCanvasAgentMutations(settingsOverride)).toBe(true); + + const invalidSettingsFallback = getCanvasAgentFeatureFlags( + { CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow" }, + { + [CANVAS_AGENT_WRITEBACK_MODE_KEY]: "not-a-mode", + } + ); + expect(invalidSettingsFallback.writebackMode).toBe("operation_shadow"); }); test("builds idempotent canvas operation audit results", () => {