Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): boolean {
return Array.isArray(obj.messages)
}

/** Read the message out of `inputs_fields`. Empty string when absent or unparseable. */
export function getScheduleMessage(
inputsText: string,
Expand All @@ -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]
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,7 +21,6 @@ import {
InputNumber,
Modal,
Select,
Tag,
TimePicker,
Typography,
message,
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<div>
<FieldLabel>At these times (UTC)</FieldLabel>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{sortTimes(times).map((t) => (
<Tag
key={fmtTime(t)}
closable={times.length > 1}
onClose={(e) => {
e.preventDefault()
removeTime(t)
}}
className="!m-0 !px-2 !py-1 !text-xs"
>
{fmtTime(t)}
</Tag>
{sorted.map((t, i) => (
<div key={fmtTime(t)} className="flex items-center">
<TimePicker
value={dayjs().hour(t.hour).minute(t.minute)}
format="HH:mm"
minuteStep={5}
needConfirm={false}
allowClear={false}
className="w-[104px]"
onChange={(d) =>
d && setTimeAt(i, {hour: d.hour(), minute: d.minute()})
}
/>
{sorted.length > 1 && (
<Button
type="text"
size="small"
aria-label={`Remove ${fmtTime(t)}`}
icon={<X size={12} />}
onClick={() => removeTimeAt(i)}
/>
)}
</div>
))}
{adding ? (
<TimePicker
Expand All @@ -419,6 +446,7 @@ function TimesField({
format="HH:mm"
minuteStep={5}
needConfirm={false}
className="w-[104px]"
defaultValue={dayjs().hour(9).minute(0)}
onChange={(d) => d && addTime({hour: d.hour(), minute: d.minute()})}
onOpenChange={(o) => !o && setAdding(false)}
Expand All @@ -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 (
<Typography.Text type="secondary" className="!text-xs">
Expand Down
Loading
Loading