diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts index 20c866354d..e18f07dee4 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts @@ -66,6 +66,12 @@ export function messageContentText(content: unknown): string { return "" } +// A `messages` payload is self-describing, so it edits as chat even when the schema says +// completion — the bound agent may be unresolved, or the mapping written by the API. +function isMessagesPayload(obj: Record): boolean { + return Array.isArray(obj.messages) +} + /** Read the message out of `inputs_fields`. Empty string when absent or unparseable. */ export function getScheduleMessage( inputsText: string, @@ -77,7 +83,7 @@ export function getScheduleMessage( // would be collapsed on save). Extra top-level keys are fine — setScheduleMessage preserves // them. Not representable → "" so the caller falls back to the raw-JSON editor. const obj = parseObject(inputsText) - if (isChat) { + if (isChat || isMessagesPayload(obj)) { const messages = obj.messages if (!Array.isArray(messages) || messages.length !== 1) return "" const message = messages[0] @@ -99,7 +105,8 @@ export function setScheduleMessage( ): string { const obj = parseObject(inputsText) const trimmed = message.trim() - if (isChat) { + // Mirrors the getter's shape check so an edit writes back where it was read from. + if (isChat || isMessagesPayload(obj)) { if (trimmed) obj.messages = [{role: "user", content: message}] else delete obj.messages } else if (trimmed) { diff --git a/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts index b986bd62e6..081687bc70 100644 --- a/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts @@ -27,6 +27,13 @@ describe("getScheduleMessage", () => { ) }) + // The bound agent may be unresolved (schema selectors report "completion" until it + // loads) or the mapping written by the API — the payload's own shape decides. + it("reads a messages payload even when the schema says completion", () => { + const json = JSON.stringify({messages: [{role: "user", content: "publish an article"}]}) + expect(getScheduleMessage(json, false, "message")).toBe("publish an article") + }) + it("returns empty string when absent or unparseable", () => { expect(getScheduleMessage("{}", true, "messages")).toBe("") expect(getScheduleMessage("{}", false, "query")).toBe("") @@ -99,6 +106,15 @@ describe("setScheduleMessage", () => { }) }) + // Must mirror the getter, or editing a messages payload under a completion schema + // would strand the original and add a stray `message` key. + it("writes back into messages when that is where the getter read from", () => { + const json = JSON.stringify({messages: [{role: "user", content: "old"}]}) + expect(JSON.parse(setScheduleMessage(json, "new", false, "message"))).toEqual({ + messages: [{role: "user", content: "new"}], + }) + }) + it("removes the key and collapses to {} when cleared", () => { expect(setScheduleMessage(JSON.stringify({query: "x"}), " ", false, "query")).toBe("{}") expect(setScheduleMessage(JSON.stringify({messages: [{}]}), "", true, "messages")).toBe( diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx index d0456e96ad..cc67bc4268 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx @@ -12,7 +12,7 @@ import { type ScheduleBuilderState, } from "@agenta/entities/gatewayTrigger" import {dayjs} from "@agenta/shared/utils" -import {Plus} from "@phosphor-icons/react" +import {Plus, X} from "@phosphor-icons/react" import { Alert, Button, @@ -21,7 +21,6 @@ import { InputNumber, Modal, Select, - Tag, TimePicker, Typography, message, @@ -363,11 +362,17 @@ function CronEditor({ } // --------------------------------------------------------------------------- -// TimesField — one or more run times as removable chips. Cron's minute and hour -// fields are independent, so a new time that would force cross-product runs is -// refused with a hint to use a second schedule. +// TimesField — one or more run times, each a live time input so "this is +// editable" needs no discovering. The last one can't be removed, so the list is +// never empty (an empty list would emit `0 0 * * *` and silently reschedule to +// midnight). Cron's minute and hour fields are independent, so a time that +// would force cross-product runs is refused with a hint to use a second +// schedule. // --------------------------------------------------------------------------- +const GRID_WARNING = + "Cron can't combine these times in one schedule — they'd trigger extra runs. Add a second schedule instead." + function TimesField({ times, onChange, @@ -376,41 +381,63 @@ function TimesField({ onChange: (times: CronTimeOfDay[]) => void }) { const [adding, setAdding] = useState(false) + const sorted = sortTimes(times) - const addTime = (t: CronTimeOfDay) => { + const commit = (next: CronTimeOfDay[]) => { setAdding(false) - if (times.some((x) => x.hour === t.hour && x.minute === t.minute)) return - const next = [...times, t] if (!timesFormCleanGrid(next)) { - message.warning( - "Cron can't combine these times in one schedule — they'd trigger extra runs. Add a second schedule instead.", - ) + message.warning(GRID_WARNING) return } onChange(sortTimes(next)) } - const removeTime = (t: CronTimeOfDay) => { - if (times.length <= 1) return - onChange(times.filter((x) => !(x.hour === t.hour && x.minute === t.minute))) + const addTime = (t: CronTimeOfDay) => { + if (sorted.some((x) => sameTime(x, t))) { + setAdding(false) + return + } + commit([...sorted, t]) + } + + // Rejected edits keep the old value: the input is controlled off `times`. + const setTimeAt = (index: number, t: CronTimeOfDay) => { + if (sorted.some((x, i) => i !== index && sameTime(x, t))) return + commit(sorted.map((x, i) => (i === index ? t : x))) + } + + const removeTimeAt = (index: number) => { + if (sorted.length <= 1) return + onChange(sorted.filter((_, i) => i !== index)) } return (
At these times (UTC)
- {sortTimes(times).map((t) => ( - 1} - onClose={(e) => { - e.preventDefault() - removeTime(t) - }} - className="!m-0 !px-2 !py-1 !text-xs" - > - {fmtTime(t)} - + {sorted.map((t, i) => ( +
+ + d && setTimeAt(i, {hour: d.hour(), minute: d.minute()}) + } + /> + {sorted.length > 1 && ( +
))} {adding ? ( d && addTime({hour: d.hour(), minute: d.minute()})} onOpenChange={(o) => !o && setAdding(false)} @@ -433,6 +461,10 @@ function TimesField({ ) } +function sameTime(a: CronTimeOfDay, b: CronTimeOfDay): boolean { + return a.hour === b.hour && a.minute === b.minute +} + function FieldLabel({children}: {children: ReactNode}) { return ( diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx index ef59629a07..ba39bf0857 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -1,9 +1,11 @@ import {useCallback, useEffect, useMemo, useState, type ReactNode} from "react" +/* Unused while the Deployed option is hidden — restore with the call site below. import { appEnvironmentsQueryAtomFamily, environmentsListQueryAtomFamily, } from "@agenta/entities/environment" +*/ import { describeCron, getScheduleMessage, @@ -46,7 +48,12 @@ import {useDraftMasterDetail} from "../../drawers/shared/useDraftMasterDetail" import {createWorkflowRevisionAdapter, type WorkflowRevisionSelectionResult} from "../../selection" import {ScheduleBuilderField} from "./ScheduleBuilderField" -import {RunVersionField, buildRunVersionReferences} from "./shared/RunVersionField" +import { + RunVersionField, + buildRunVersionReferences, + extractBoundWorkflowId, + isRunVersionBound, +} from "./shared/RunVersionField" // Weekly (Monday 09:00 UTC) so the builder opens on the Weekly cadence by default. const DEFAULT_CRON = "0 9 * * 1" @@ -87,6 +94,14 @@ function normalizeJson(text: string): string { } } +// Seed id for a create-mode default-bind. Variant before revision: the value is written +// back under `application_variant`, so pinning a revision id here would mislabel it. +function extractDefaultBindId( + refs: Record | null | undefined, +): string | null { + return refs?.application_variant?.id ?? refs?.application_revision?.id ?? null +} + // --------------------------------------------------------------------------- // TriggerScheduleDrawer (root) — create or edit a schedule. // @@ -389,8 +404,10 @@ function ScheduleForm({ const [bindMode, setBindMode] = useState<"revision" | "environment">("revision") const [environmentSlug, setEnvironmentSlug] = useState(null) const [appSlug, setAppSlug] = useState(null) + /* Unused while the Deployed option is hidden — restore with the call site below. const envQuery = useAtomValue(environmentsListQueryAtomFamily(false)) const environments = envQuery.data?.environments ?? [] + */ // Resolve the bound revision id to a human label (edit-mode prefill stores only // the id) — app name / variant name · vN. These are sync atoms (null for unknown). @@ -433,6 +450,7 @@ function ScheduleForm({ // Environment options: in a playground, scope to the environments this agent is // actually deployed to (not every project environment); settings lists them all. + /* Unused while the Deployed option is hidden — restore with the call site below. const appIdForEnv = playgroundEntityId ? (playgroundWorkflow?.workflow_id ?? playgroundEntityId) : "" @@ -451,6 +469,7 @@ function ScheduleForm({ : (d.name ?? d.slug ?? ""), })) }, [playgroundEntityId, environments, appDeployments.data]) + */ // Prefill from the freshly-fetched schedule (edit mode). useEffect(() => { @@ -467,12 +486,7 @@ function ScheduleForm({ setEnvironmentSlug(envRef.slug ?? null) setAppSlug(refs?.application?.slug ?? null) } else { - const wfId = - refs?.application_revision?.id ?? - refs?.application_variant?.id ?? - refs?.workflow_revision?.id ?? - null - setWorkflowRevId(wfId) + setWorkflowRevId(extractBoundWorkflowId(refs)) // Label is resolved from the revision id below, not stored as the raw id. } setInputsText(JSON.stringify(schedule.data?.inputs_fields ?? {}, null, 2)) @@ -487,7 +501,7 @@ function ScheduleForm({ if (isEdit) return const refs = state?.defaultReferences setAppSlug(refs?.application?.slug ?? null) - const variantId = refs?.application_variant?.id ?? refs?.application_revision?.id ?? null + const variantId = extractDefaultBindId(refs) if (!variantId) return const appId = refs?.application?.id ?? null const label = state?.defaultBoundLabel ?? appId ?? variantId @@ -511,6 +525,15 @@ function ScheduleForm({ const cronValidation = useMemo(() => validateCron(cron), [cron]) + // The binding as persisted — the picker's leaf can't represent every shape the BE accepts. + const storedReferences = schedule?.data?.references + const versionChosen = isRunVersionBound({ + bindMode, + workflowRevId, + environmentSlug, + storedReferences, + }) + // Save enables only on draft changes vs the starting point (loaded schedule in // edit, defaults in new). Normalized JSON so formatting isn't a change. const baselineSnapshot = useMemo(() => { @@ -525,11 +548,7 @@ function ScheduleForm({ enabled: isEntityActive(schedule), bindMode: envRef ? "environment" : "revision", environmentSlug: envRef?.slug ?? null, - workflowRevId: - refs?.application_revision?.id ?? - refs?.application_variant?.id ?? - refs?.workflow_revision?.id ?? - null, + workflowRevId: extractBoundWorkflowId(refs), inputs: normalizeJson(JSON.stringify(schedule.data?.inputs_fields ?? {})), }) } @@ -544,7 +563,7 @@ function ScheduleForm({ enabled: true, bindMode: "revision", environmentSlug: null, - workflowRevId: refs?.application_variant?.id ?? refs?.application_revision?.id ?? null, + workflowRevId: extractDefaultBindId(refs), inputs: normalizeJson("{}"), }) }, [isEdit, schedule, state?.defaultReferences]) @@ -592,7 +611,7 @@ function ScheduleForm({ message.error("This schedule isn't linked to an app — use Pinned (a specific revision)") return } - if (bindMode === "revision" && !workflowRevId) { + if (bindMode === "revision" && !versionChosen) { message.error("Bind a workflow") return } @@ -619,7 +638,7 @@ function ScheduleForm({ appSlug, workflowSelection, workflowRevId, - fallbackReferences: schedule?.data?.references, + fallbackReferences: storedReferences, }) const data: TriggerScheduleData = { @@ -688,6 +707,7 @@ function ScheduleForm({ appSlug, workflowRevId, workflowSelection, + versionChosen, inputsText, isEdit, schedule, @@ -702,7 +722,6 @@ function ScheduleForm({ // Per-section header state: icon tint (complete / warning / default) and a // collapsed summary of what's configured. const cronValid = cronValidation.valid - const versionChosen = bindMode === "revision" ? !!workflowRevId : !!environmentSlug const versionSummary = bindMode === "revision" ? (workflowLabel ?? resolvedRevisionName ?? undefined) @@ -826,6 +845,9 @@ function ScheduleForm({ label = label ? `${label} · v${m.revision}` : `v${m.revision}` setWorkflowLabel(label || selection.label) }} + hideEnvironment + /* Deployed option temporarily hidden — drop `hideEnvironment` + and uncomment to restore. envOptions={envOptions} envLoading={ playgroundEntityId ? appDeployments.isLoading : envQuery.isLoading @@ -837,6 +859,7 @@ function ScheduleForm({ ? "This agent isn't deployed to any environment yet." : undefined } + */ /> @@ -852,7 +875,6 @@ function ScheduleForm({ void - isEdit: boolean isChat: boolean primaryKey: string disabled?: boolean }) { - // Open in Advanced (raw JSON) when editing a saved mapping the simple composer can't - // reproduce, so the first edit doesn't collapse it to a single message. - const [rawMode, setRawMode] = useState( - () => - isEdit && - !!inputsText.trim() && - getScheduleMessage(inputsText, isChat, primaryKey) === "", - ) + // Always opens on the message; raw JSON is opt-in via "Advanced" below. + const [rawMode, setRawMode] = useState(false) const rawValid = useMemo(() => { const t = inputsText.trim() @@ -1098,6 +1113,8 @@ function MessageComposer({ } const message = getScheduleMessage(inputsText, isChat, primaryKey) + // The composer writes a single user message, so anything richer would be lost on edit. + const wouldReplace = !message && !!inputsText.trim() && inputsText.trim() !== "{}" return (
- - Sent to the agent{" "} - {isChat ? "as the user message" : `as the "${primaryKey}" input`} on each run. + + {wouldReplace ? ( + "This mapping is richer than one message — typing here replaces it. Edit it under Advanced." + ) : ( + <> + Sent to the agent{" "} + {isChat ? "as the user message" : `as the "${primaryKey}" input`} on + each run. + + )}