diff --git a/groups_rules_cli.go b/groups_rules_cli.go index a7869eb4..fff4a067 100644 --- a/groups_rules_cli.go +++ b/groups_rules_cli.go @@ -15,7 +15,8 @@ import ( const ruleUsage = `usage: magpie group rule the group's rules magpie group rule add use= [tokens=] [images] [effort=on|low|medium|high|xhigh|max] [agents=a,b…] - [intent=""] [compact] [classifier=] [at=] + [intent=""] [compact] [time=HH:MM-HH:MM] [days=mon-fri] + [classifier=] [at=] a rule: a turn that matches it goes to , one of the group's (or group/, a group in it), first; a model in the group at an effort of its own is named with it (use=glm/glm-5.3-flash:high) @@ -42,11 +43,16 @@ const ruleUsage = `usage: OpenCode's, Pi's, Gemini CLI's, Qwen Code's, Kimi's): a cheaper, faster model can write the summary. The request is as long as the conversation, so a model known to take less is passed over; it is not the turn's model, and the requests after it go on as before + time the turn begins within these hours, on this computer's clock (09:00-18:00; 22:00-08:00 + runs past midnight) — a provider's peak-price hours sent to another, say + days only on these days (mon-fri, sat,sun); with time, a window past midnight is of the day it + begins on. Days alone hold all day e.g. magpie group rule add opus-anywhere use=openrouter/google/gemini-3-pro tokens=200k magpie group rule add opus-anywhere use=a/vision-model images magpie group rule add fast use=codex/gpt-5.6-sol:xhigh effort=high magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact + magpie group rule add cheap use=glm/glm-5.3 time=14:00-18:00 days=mon-fri magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash intent="a quick question" classifier=groq/llama-3.1-8b-instant` // parseTokens reads 200000, 200k, 1.5m. diff --git a/internal/gateway/rules.go b/internal/gateway/rules.go index 7d112ba6..a95bbaa3 100644 --- a/internal/gateway/rules.go +++ b/internal/gateway/rules.go @@ -18,6 +18,10 @@ import ( "github.com/yetone/magpie/internal/provider" ) +// ruleClock is the time a turn's rules look at (a rule's hours), and its +// decision is kept from; tests set it. +var ruleClock = time.Now + // RuleHit is what the trace tells of a group's rules for a request. type RuleHit struct { N int `json:"n"` // the rule, from 1; 0 when none matched @@ -131,14 +135,14 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a return nil } turn, within := turnIn(req) - q := provider.RuleRequest{Tokens: estimate(req), Thinking: req.Thinking, Effort: req.Effort, Agent: agent} + now := ruleClock() + q := provider.RuleRequest{Tokens: estimate(req), Thinking: req.Thinking, Effort: req.Effort, Agent: agent, At: now} for _, m := range req.Messages { if slices.ContainsFunc(m.Parts, func(p Part) bool { return p.Kind == Image }) { q.Images = true break } } - now := time.Now() turnRules.Lock() tr, had := turnRules.m[key] turnRules.Unlock() diff --git a/internal/gateway/rules_time_test.go b/internal/gateway/rules_time_test.go new file mode 100644 index 00000000..969c5c73 --- /dev/null +++ b/internal/gateway/rules_time_test.go @@ -0,0 +1,64 @@ +package gateway + +import ( + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// A rule's hours are looked at as a turn begins, on the clock then: a turn +// begun inside them goes to its member and stays there for its tool +// rounds after they end; a turn begun outside them isn't the rule's (where +// the same conversation goes then is the group's Stays to say). +func TestRuleTimeWindowAtTurnStart(t *testing.T) { + now := time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) // a Tuesday + ruleClock = func() time.Time { return now } + t.Cleanup(func() { ruleClock = time.Now }) + s, a, b := ruled(t, provider.Rule{Use: "b/big", Time: &provider.TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}}) + post := func(body string, session string) (string, Route) { + t.Helper() + code, out := postAs(t, s, session, body) + if code != 200 { + t.Fatalf("%d %s", code, out) + } + return out, lastRoute(s) + } + // turn 1 begins inside the hours: the rule's member, the trace says why + out, r := post(chat("hello", nil, 0, ""), "sess") + if !strings.Contains(out, "from kb") || r.Rule == nil || r.Rule.N != 1 || r.Rule.Use != "b/big" || strings.Join(r.Rule.When, " · ") != "time 09:00–18:00 Mon–Fri" { + t.Fatalf("turn 1: %s %+v", out, r.Rule) + } + // its tool rounds after the hours end stay where it began + now = time.Date(2026, 9, 29, 18, 30, 0, 0, time.Local) + out, r = post(chat("hello", nil, 1, ""), "sess") + if !strings.Contains(out, "from kb") || !r.Rule.Held || r.Rule.N != 1 { + t.Fatalf("turn 1 tool round: %s %+v", out, r.Rule) + } + // turn 2 begins after them: no rule + if _, r = post(chat("hello", []string{"more"}, 0, ""), "sess"); r.Rule.N != 0 || r.Rule.Held { + t.Fatalf("turn 2: %+v", r.Rule) + } + // nor another conversation's: the group's order + if out, r = post(chat("hi", nil, 0, ""), "s2"); !strings.Contains(out, "from ka") || r.Rule.N != 0 { + t.Fatalf("after hours: %s %+v", out, r.Rule) + } + // a turn begun before them stays off the rule when they open mid-turn + now = time.Date(2026, 9, 30, 8, 59, 0, 0, time.Local) + if out, r = post(chat("hello", nil, 0, ""), "s3"); !strings.Contains(out, "from ka") || r.Rule.N != 0 { + t.Fatalf("before hours: %s %+v", out, r.Rule) + } + now = time.Date(2026, 9, 30, 9, 1, 0, 0, time.Local) + if out, r = post(chat("hello", nil, 1, ""), "s3"); !strings.Contains(out, "from ka") || !r.Rule.Held || r.Rule.N != 0 { + t.Fatalf("before hours, tool round: %s %+v", out, r.Rule) + } + // on a Saturday, the hours don't hold + now = time.Date(2026, 10, 3, 10, 0, 0, 0, time.Local) + if out, r = post(chat("hello", nil, 0, ""), "s4"); !strings.Contains(out, "from ka") || r.Rule.N != 0 { + t.Fatalf("saturday: %s %+v", out, r.Rule) + } + if a.n() != 4 || b.n() != 3 { + t.Fatalf("a %d b %d", a.n(), b.n()) + } +} diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 77edcad8..f6a50bfc 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1248,6 +1248,26 @@ const I18N = { "Time": "时间", "has an image": "带图片", "compacting": "压缩上下文时", + "from": "从", + "to": "到", + "The turn begins within these hours, on this computer's clock — a provider's peak-price hours sent to another, say": "这一轮在这个时段内开始时(按本机时间)——比如把某家服务商高峰计价的时段转给另一家", + "every day": "每天", + "days": "星期", + "weekdays": "工作日", + "weekends": "周末", + "Mon": "周一", + "Tue": "周二", + "Wed": "周三", + "Thu": "周四", + "Fri": "周五", + "Sat": "周六", + "Sun": "周日", + "{from}–{to}": "{from}–{to}", + "all day": "全天", + "{hours} {days}": "{days} {hours}", + "runs past midnight, into the next day": "跨过午夜,到第二天", + "Rule {n}: the hours are two times like 09:00 and 18:00": "第 {n} 条规则:时段要填两个时间,例如 09:00 和 18:00", + "Rule {n}: its hours are the whole day — pick days, or other hours": "第 {n} 条规则:这个时段是全天——请选择星期,或换个时段", "The agent summarizes the conversation to go on in less room (Claude Code's /compact, Codex, OpenCode…): a cheaper, faster model can do it": "Agent 总结对话以腾出上下文(Claude Code 的 /compact、Codex、OpenCode…):可交给更便宜、更快的模型来做", "longer conversations skip it: it takes {n} tokens": "更长的对话会跳过它:它的上下文只有 {n} tokens", "The agent is compacting the conversation, so rule {n} sends the summary to {use}; the conversation stays on the model it was on.": "Agent 正在压缩上下文,规则 {n} 把这次总结交给 {use};对话本身仍留在原来的模型上。", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 1514cbea..f62a4c2f 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -326,6 +326,11 @@ .rt-cond.tk { cursor: text; } .rt-cond.in { cursor: text; } .rt-cond.in input, .editor .rt-cond.in input[type="text"] { width: 24ch; } +/* a rule's hours: two times in the pill, typed; one that isn't a time is marked */ +.rt-cond.tm { cursor: text; } +.rt-cond.tm input.clock, .editor .rt-cond.tm input.clock[type="text"] { width: 5.5ch; text-align: center; } +.rt-cond.tm.bad { border-style: solid; border-color: var(--amber, var(--muted)); } +.rt-cond[hidden] { display: none; } .rt-classifier { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; } .rt-classifier[hidden] { display: none; } .rt-classifier .w { color: var(--muted); font-size: 12px; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 01b62a5e..1bb028f8 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -415,8 +415,43 @@ if (c === "compacting") return t("compacting"); if ((m = /^effort ≥ (\w+)$/.exec(c))) return t("reasoning ≥ {level}", { level: m[1] }); if ((m = /^agent (.+)$/.exec(c))) return m[1].split("|").map(agentName).join(" / "); + if ((m = /^time (all day|(\d\d:\d\d)–(\d\d:\d\d))(?: (.+))?$/.exec(c))) { + // the days as the gateway writes them: Mon–Fri, Sat,Sun, Mon,Wed–Fri + const days = []; + for (const part of (m[4] || "").split(",").filter(Boolean)) { + const [a, b] = part.split("–").map((d) => WEEK.indexOf(d.toLowerCase())); + for (let i = a; i >= 0 && i <= (b >= 0 ? b : a); i++) days.push(WEEK[i]); + } + return timeText({ from: m[2] || "00:00", to: m[3] || "00:00", days }); + } return c; } + // a rule's hours (provider.TimeWindow): local time, past midnight when + // they end before they begin, on the days named or every day + const WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + const DAY_NAMES = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" }; + function daysText(days) { + const on = WEEK.map((d) => (days || []).includes(d)), out = []; + for (let i = 0; i < 7; i++) { + if (!on[i]) continue; + let j = i; + while (j + 1 < 7 && on[j + 1]) j++; + if (j - i >= 2) out.push(t("{from}–{to}", { from: t(DAY_NAMES[WEEK[i]]), to: t(DAY_NAMES[WEEK[j]]) })); + else for (let k = i; k <= j; k++) out.push(t(DAY_NAMES[WEEK[k]])); + i = j; + } + return out.length === 7 || !out.length ? "" : out.join(t(", ")); + } + function timeText(w) { + const hours = w.from === w.to ? t("all day") : t("{from}–{to}", { from: w.from, to: w.to }); + const days = daysText(w.days); + return days ? t("{hours} {days}", { hours, days }) : hours; + } + // "9:5" is no time; "9:05" is 09:05 + const clockOf = (s) => { + const m = /^\s*(\d{1,2}):(\d{2})\s*$/.exec(s || ""); + return m && +m[1] < 24 && +m[2] < 60 ? m[1].padStart(2, "0") + ":" + m[2] : ""; + }; // how the reasoning a try was sent at came to be function effortNote(r, tr) { @@ -1870,6 +1905,7 @@ if (r.agents?.length) bits.push(r.agents.map((id) => (state.clients || state.agents || []).find((a) => a.id === id)?.name || id).join(" / ")); if (r.intent) bits.push(t("asks for “{intent}”", { intent: r.intent })); if (r.compact) bits.push(t("compacting")); + if (r.time) bits.push(timeText(r.time)); return bits.join(" · "); } const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); @@ -1963,7 +1999,7 @@ if (!g.ready) tags.append(el("span", "tag bad", t("no member ready"))); const edit = el("button", "text", t("Edit")); edit.onclick = (e) => { e.stopPropagation(); open(); }; - const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); }; + const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); }; row.onclick = open; row.append(ics, main, tags, edit); return row; @@ -2173,7 +2209,47 @@ const cp = el("button", "rt-cond" + (r.compact ? " on" : ""), t("compacting")); cp.title = t("The agent summarizes the conversation to go on in less room (Claude Code's /compact, Codex, OpenCode…): a cheaper, faster model can do it"); cp.onclick = () => { r.compact = !r.compact; cp.classList.toggle("on", r.compact); warn(); }; - when.append(tk, im, ef, ag, it, cp); + // hours of the day, on this computer's clock (a vendor's peak + // hours, say): typed as 09:00 and 18:00, past midnight when the + // second comes first; the days are picked like the agents + const tm = el("span", "rt-cond tm"); + const tw = () => r.time || (r.time = { from: "", to: "", days: [] }); + const tFrom = input(r.time?.from || "", "09:00"), tTo = input(r.time?.to || "", "18:00"); + tm.title = t("The turn begins within these hours, on this computer's clock — a provider's peak-price hours sent to another, say"); + tm.onclick = (e) => { if (e.target === tm || e.target.tagName === "SPAN") (tFrom.value ? tTo : tFrom).focus({ preventScroll: true }); }; + const dayBtn = el("button", "rt-cond"); + const drawTime = () => { + const w = r.time, from = clockOf(w?.from), to = clockOf(w?.to); + const typed = !!(w && (w.from || w.to)); + tm.classList.toggle("on", !!(from && to)); + // a time typed that isn't one, once its field is left + tm.classList.toggle("bad", [[tFrom, w?.from], [tTo, w?.to]].some(([b, v]) => v && !clockOf(v) && document.activeElement !== b)); + dayBtn.hidden = !typed && !w?.days?.length; + dayBtn.textContent = daysText(w?.days) || t("every day"); + dayBtn.classList.toggle("on", !!daysText(w?.days)); + }; + for (const [box, key] of [[tFrom, "from"], [tTo, "to"]]) { + box.className = "clock"; + box.maxLength = 5; + box.inputMode = "numeric"; + box.oninput = () => { tw()[key] = box.value.trim(); drawTime(); warn(); }; + box.onblur = () => { const v = clockOf(box.value); if (v) { box.value = v; tw()[key] = v; } drawTime(); warn(); }; + box.onkeydown = (e) => e.stopPropagation(); + } + tm.append(el("span", "", t("from")), tFrom, el("span", "", t("to")), tTo); + const presets = [["", "every day", []], ["weekdays", "weekdays", WEEK.slice(0, 5)], ["weekends", "weekends", WEEK.slice(5)]]; + dayBtn.onclick = (ev) => pickFrom("days", dayBtn, ev, [ + ...presets.map(([v, label, ds]) => ({ value: "=" + v, label: t(label), note: ds.length ? daysText(ds) : t("not a condition") })), + ...WEEK.map((d) => ({ value: d, label: t(DAY_NAMES[d]), note: r.time?.days?.includes(d) ? "✓" : "" })), + ], (v) => { + const w = tw(); + if (v.startsWith("=")) w.days = [...presets.find(([id]) => "=" + id === v)[2]]; + else w.days = w.days.includes(v) ? w.days.filter((x) => x !== v) : WEEK.filter((x) => x === v || w.days.includes(x)); + if (w.days.length === 7) w.days = []; + drawTime(); warn(); + }); + drawTime(); + when.append(tk, im, ef, ag, it, cp, tm, dayBtn); // the member it sends to const use = el("div", "rt-use"); const ub = el("button", "rt-cond on"); @@ -2188,6 +2264,8 @@ if (r.tokens && m?.context && m.context < r.tokens) bits.push(t("it takes {n} tokens", { n: m.context.toLocaleString() })); // a summary longer than it takes goes by the next rule, or the group if (r.compact && m?.context && d.members.some((id) => (infoOf(id)?.context || 0) > m.context)) bits.push(t("longer conversations skip it: it takes {n} tokens", { n: m.context.toLocaleString() })); + const from = clockOf(r.time?.from), to = clockOf(r.time?.to); + if (from && to && to < from) bits.push(t("runs past midnight, into the next day")); hint.textContent = bits.join(" · "); }; warn(); @@ -2202,7 +2280,7 @@ }); drawClassifier(); }; - rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false }); drawRules(); }; + rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false, time: null }); drawRules(); }; // the classifier, once a rule has an intent or the effort is picked // per turn: the model asked which intent a turn's message is and how // hard it is. Jev (a decision provider's model) answers both in one @@ -2314,7 +2392,23 @@ const save = () => { if (!d.members.length) { addBtn.focus({ preventScroll: true }); return status(t("A group needs a model in it"), "warn"); } d.rules.forEach((r) => { r.intent = (r.intent || "").trim(); }); - const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact); + // hours: both times, or none and no days; days alone are all day + const badTime = d.rules.findIndex((r) => { + const w = r.time; + if (!w || !w.from && !w.to && !w.days?.length) return r.time = null, false; + if (!w.from && !w.to) { w.from = w.to = "00:00"; return false; } + const from = clockOf(w.from), to = clockOf(w.to); + if (!from || !to) return true; + w.from = from; w.to = to; + return from === to && !w.days?.length; + }); + if (badTime >= 0) { + const w = d.rules[badTime].time; + return status(clockOf(w.from) && clockOf(w.from) === clockOf(w.to) + ? t("Rule {n}: its hours are the whole day — pick days, or other hours", { n: badTime + 1 }) + : t("Rule {n}: the hours are two times like 09:00 and 18:00", { n: badTime + 1 }), "warn"); + } + const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact && !r.time); if (bare >= 0) return status(t("Rule {n} needs a condition", { n: bare + 1 }), "warn"); if (d.rules.some((r) => r.intent) && !d.classifier) return status(t("Choose the model that tells which intent a message is"), "warn"); if (d.effort === "auto" && !d.classifier) return status(t("Choose the model that rates how hard a turn is"), "warn"); diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 0ffa64c4..e33911a0 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -651,10 +651,20 @@ container's own and to set `MAGPIE_PUBLIC_URL`. Outside a container nothing changes. No coloured left border; English and Chinese, Chromium and WebKit, with `/api/plugins` faked. +`routing-time.test.cjs` checks a routing group's rule for hours of the day +(Discord: send a provider's peak-price hours, DeepSeek's or GLM's, to +another). The group's rule and the trace read it as its hours and days +(22:00–08:00 Mon–Fri; 周一–周五 22:00–08:00); in the editor the hours are two +times typed into one pill, a click on it types the first and moves nothing, +past midnight is noted, a time that isn't one is marked once left, the days +are picked like the agents (weekdays, then Saturday too: Mon–Sat), and the +rule is saved as `time: {from, to, days}`. English and Chinese, Chromium and +WebKit, with `/api/plugins` faked. + With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/routing-time.test.cjs b/internal/gui/tests/routing-time.test.cjs new file mode 100644 index 00000000..5481af59 --- /dev/null +++ b/internal/gui/tests/routing-time.test.cjs @@ -0,0 +1,164 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A routing group's rule may hold only at some hours of the day (a Discord +// user: send a provider's peak-price hours — DeepSeek's, GLM's — to +// another). A group's rule for them reads as its hours and days; the trace +// tells the rule that matched by them; in the editor the hours are two +// times typed into one pill, past midnight noted, the days picked like the +// agents, a time that isn't one marked, nothing moved by a click, and they +// are saved as time:{from,to,days}. In English and Chinese. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const models = [ + { id: "a/glm", name: "glm", providerName: "A", icon: "generic", context: 200000, ready: true }, + { id: "b/other", name: "other", providerName: "B", icon: "generic", context: 200000, ready: true }, +]; +const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri"]; +const groups = () => ({ + models, + groups: [{ id: "main", name: "Main", members: ["a/glm", "b/other"], routing: "order", ready: true, + memberInfo: [{ id: "a/glm", ready: true, context: 200000 }, { id: "b/other", ready: true, context: 200000 }], + rules: [{ use: "b/other", time: { from: "22:00", to: "08:00", days: WEEKDAYS } }] }], + pools: [], +}); +const now = Date.now(); +const route = { + id: 7, seq: 7, time: new Date(now - 2000).toISOString(), agent: "claude", model: "main", provider: "b", done: true, status: 200, ms: 900, tokens: 9000, + group: { id: "main", name: "Main", members: ["a/glm", "b/other"] }, + order: [{ id: "b", provider: "b", name: "B", kind: "key", model: "other", known: true }, { id: "a", provider: "a", name: "A", kind: "key", model: "glm", known: true }], + tries: [{ id: "b", model: "other", start: new Date(now - 2000).toISOString(), done: true, ms: 800, status: 200 }], + rule: { n: 1, use: "b/other", when: ["time 22:00–08:00 Mon–Fri"], turn: 2, tokens: 9000 }, +}; + +const words = { + en: { cond: "22:00–08:00 Mon–Fri", story: "rule 1 matches — 22:00–08:00 Mon–Fri —", night: "runs past midnight, into the next day", every: "every day", weekdays: "weekdays", sat: "Sat", monSat: "Mon–Sat", edit: "Edit", add: "Add a rule", save: "Save" }, + zh: { cond: "周一–周五 22:00–08:00", story: "规则 1", night: "跨过午夜,到第二天", every: "每天", weekdays: "工作日", sat: "周六", monSat: "周一–周六", edit: "编辑", add: "添加规则", save: "保存" }, +}; + +function serve(lang, posts) { + const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + let first = true; + return async (r) => { + const url = new URL(r.request().url()); + const json = (data) => r.fulfill({ json: data }); + if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes + const routes = first ? [route] : []; + first = false; + return json({ mine: true, now: new Date().toISOString(), seq: 7, totals: { requests: 1, rerouted: 0, errors: 0 }, routes }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/groups") return json(groups()); + if (url.pathname.startsWith("/api/groups/")) { + posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") }); + return json(groups()); + } + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await r.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang]; + test(`${engine} ${lang}: a rule for hours of the day`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 } })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=routing"); + const group = page.locator(".rt-group").first(); + await group.waitFor(); + + // the trace: the rule that matched, told by its hours + await page.waitForFunction((s) => document.documentElement.innerText.includes(s), w.cond); + const said = await page.evaluate(() => document.documentElement.innerText); + assert(said.includes(w.story), `the rule that matched is told: ${said.slice(0, 400)}`); + + // the group's rule, in words + const title = await group.evaluate((g) => [...g.querySelectorAll("[title]")].map((e) => e.title).join("\n")); + assert(title.includes(`1. ${w.cond} →`), `the rule reads as its hours: ${title}`); + + // the editor: the hours in their pill, the days on theirs, past midnight noted + await group.locator("button", { hasText: w.edit }).click(); + const ed = page.locator(".rt-gedit"); + const rows = ed.locator(".rt-rule"); + await rows.first().waitFor(); + const tm1 = rows.first().locator(".rt-cond.tm"); + assert.equal(await tm1.getAttribute("class"), "rt-cond tm on"); + assert.deepEqual(await tm1.locator("input").evaluateAll((xs) => xs.map((x) => x.value)), ["22:00", "08:00"]); + const days1 = rows.first().locator("button.rt-cond", { hasText: lang === "en" ? "Mon–Fri" : "周一–周五" }); + assert.equal(await days1.getAttribute("class"), "rt-cond on"); + assert.equal(await rows.first().locator(".rt-rwarn").textContent(), w.night); + + // a second rule: its hours typed, nothing moved by a click + await ed.locator("button", { hasText: w.add }).click(); + const row2 = rows.nth(1); + const tm2 = row2.locator(".rt-cond.tm"); + await tm2.waitFor(); + const dayBtn2 = row2.locator("button.rt-cond", { hasText: w.every }); + assert.equal(await dayBtn2.isVisible(), false, "no days until hours are typed"); + const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top)); + await tm2.scrollIntoViewIfNeeded(); // by the test, as the reader would + await page.waitForTimeout(200); + const before = await at(); + const label = tm2.locator("span").first(); + await label.click(); + assert.equal(await at(), before, "the click moved nothing"); + const [from2, to2] = [tm2.locator("input").nth(0), tm2.locator("input").nth(1)]; + assert.equal(await from2.evaluate((x) => x === document.activeElement), true, "a click on the pill types its first time"); + await page.keyboard.type("25:00"); + await to2.click(); + assert.match(await tm2.getAttribute("class"), /\bbad\b/, "a time that isn't one is marked once left"); + assert.equal(await at(), before, "the click moved nothing"); + await from2.fill("9:00"); + await to2.fill("18:00"); + await to2.blur(); + assert.equal(await from2.inputValue(), "09:00"); + assert.equal(await tm2.getAttribute("class"), "rt-cond tm on"); + assert.equal(await row2.locator(".rt-rwarn").textContent(), ""); + + // the days: weekdays, and Saturday too + await dayBtn2.waitFor(); + await dayBtn2.click(); + assert.equal(await at(), before, "the click moved nothing"); + await page.locator("#list li").filter({ hasText: w.weekdays }).first().click(); + const days2 = row2.locator(".when > button.rt-cond").last(); + assert.equal(await days2.textContent(), lang === "en" ? "Mon–Fri" : "周一–周五"); + await days2.click(); + await page.locator("#list li").filter({ hasText: w.sat }).last().click(); // the day's own, after weekends + assert.equal(await days2.textContent(), w.monSat); + assert.equal(await days2.getAttribute("class"), "rt-cond on"); + + // saved: time:{from,to,days}, neither rule refused as having no condition + const saveBtn = ed.locator("button.primary", { hasText: w.save }); + await page.mouse.move(550, 600); + await page.mouse.wheel(0, 600); + await page.waitForTimeout(300); + const sb = await saveBtn.boundingBox(); + await page.mouse.click(sb.x + sb.width / 2, sb.y + sb.height / 2); + await page.waitForFunction(() => !document.querySelector(".rt-gedit")); + const save = posts.find((p) => p.path === "/api/groups/save"); + assert(save, `saved: ${JSON.stringify(posts)}`); + assert.deepEqual(save.body.rules.map((r) => [r.use, r.time]), [ + ["b/other", { from: "22:00", to: "08:00", days: WEEKDAYS }], + ["b/other", { from: "09:00", to: "18:00", days: [...WEEKDAYS, "sat"] }], + ]); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/grouprule.go b/internal/provider/grouprule.go index 3fa57e7d..f22d3845 100644 --- a/internal/provider/grouprule.go +++ b/internal/provider/grouprule.go @@ -4,7 +4,8 @@ package provider // seen in the request itself — how long it is, whether it carries an // image, how hard the agent asked the model to think, which agent sent it. // Those are never guessed: the same request is always routed the same way, -// and the trace tells which rule did it. The one exception is a rule with +// and the trace tells which rule did it. A rule may also hold only at some +// hours of the day (a vendor's peak-price hours, say), on magpie's clock. The one exception is a rule with // an intent (what the user asks for, in the user's words): as a turn // begins, a model the group names (its Classifier) is asked which of the // intents the user's message is, once, and the trace tells what it said. @@ -18,7 +19,9 @@ package provider import ( "fmt" "slices" + "strconv" "strings" + "time" ) // Efforts are the reasoning levels a rule can ask for, lowest first. @@ -48,6 +51,151 @@ type Rule struct { // Codex's, OpenCode's, Pi's…) — which a cheaper, faster model can do // in place of the one the conversation is on. Compact bool `json:"compact,omitempty"` + // Time: the turn begins within these hours of the day, local time — a + // vendor's peak-price hours sent to another, say. + Time *TimeWindow `json:"time,omitempty"` +} + +// TimeWindow is hours of the day, local time: From until To, "HH:MM" each, +// past midnight when To comes before From (22:00–08:00), the whole day +// when they are the same. Days, when set, are the days it holds on ("mon" +// … "sun"); a window past midnight is of the day it begins on, so Friday's +// 22:00–08:00 takes Saturday's early hours too. +type TimeWindow struct { + From string `json:"from"` + To string `json:"to"` + Days []string `json:"days,omitempty"` +} + +// Weekdays are the days a TimeWindow names, as time.Weekday counts them. +var Weekdays = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} + +// dayOrder is the week as a window shows it, Monday first. +var dayOrder = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"} + +// clockMinutes reads "HH:MM" (or "H:MM") as minutes past midnight. +func clockMinutes(s string) (int, bool) { + h, m, ok := strings.Cut(strings.TrimSpace(s), ":") + if !ok || len(h) < 1 || len(h) > 2 || len(m) != 2 { + return 0, false + } + hh, err1 := strconv.Atoi(h) + mm, err2 := strconv.Atoi(m) + if err1 != nil || err2 != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 || h[0] == '+' || m[0] == '+' { + return 0, false + } + return hh*60 + mm, true +} + +// ParseDay reads a day of the week, "mon" or "Monday", as Weekdays has it. +func ParseDay(s string) (string, bool) { + s = strings.ToLower(strings.TrimSpace(s)) + if len(s) < 3 { + return "", false + } + for i, d := range Weekdays { + if strings.HasPrefix(strings.ToLower(time.Weekday(i).String()), s) { + return d, true + } + } + return "", false +} + +// clean checks a window and puts it the one way: "09:00", days known, +// each once in the week's order, none when it is every day. +func (w *TimeWindow) clean() error { + from, ok := clockMinutes(w.From) + if !ok { + return fmt.Errorf("the time %q is not HH:MM (09:00)", w.From) + } + to, ok := clockMinutes(w.To) + if !ok { + return fmt.Errorf("the time %q is not HH:MM (18:00)", w.To) + } + w.From, w.To = fmt.Sprintf("%02d:%02d", from/60, from%60), fmt.Sprintf("%02d:%02d", to/60, to%60) + var days []string + for _, d := range w.Days { + v, ok := ParseDay(d) + if !ok { + return fmt.Errorf("%q is not a day of the week (mon … sun)", d) + } + days = append(days, v) + } + w.Days = nil + for _, d := range dayOrder { + if slices.Contains(days, d) { + w.Days = append(w.Days, d) + } + } + if len(w.Days) == len(dayOrder) { + w.Days = nil + } + return nil +} + +// Holds reports whether at is within the window, on the machine's clock. +func (w TimeWindow) Holds(at time.Time) bool { + from, ok1 := clockMinutes(w.From) + to, ok2 := clockMinutes(w.To) + if at.IsZero() || !ok1 || !ok2 { + return false + } + at = at.Local() + m, day := at.Hour()*60+at.Minute(), at.Weekday() + switch { + case from == to: // the whole day + case from < to: + if m < from || m >= to { + return false + } + default: // past midnight + if m >= to && m < from { + return false + } + if m < to { + day = (day + 6) % 7 // the window began the day before + } + } + return len(w.Days) == 0 || slices.Contains(w.Days, Weekdays[day]) +} + +// DaysText is the window's days for a list: "Mon–Fri", "Sat,Sun", +// "Mon,Wed–Fri"; "" for every day. +func (w TimeWindow) DaysText() string { + name := func(d string) string { return strings.ToUpper(d[:1]) + d[1:] } + var out []string + for i := 0; i < len(dayOrder); { + if !slices.Contains(w.Days, dayOrder[i]) { + i++ + continue + } + j := i + for j+1 < len(dayOrder) && slices.Contains(w.Days, dayOrder[j+1]) { + j++ + } + switch j - i { + case 0: + out = append(out, name(dayOrder[i])) + case 1: + out = append(out, name(dayOrder[i]), name(dayOrder[j])) + default: + out = append(out, name(dayOrder[i])+"–"+name(dayOrder[j])) + } + i = j + 1 + } + return strings.Join(out, ",") +} + +// Text is the window as a rule's conditions show it: "09:00–18:00 Mon–Fri". +func (w TimeWindow) Text() string { + s := w.From + "–" + w.To + if w.From == w.To { + s = "all day" + } + if d := w.DaysText(); d != "" { + s += " " + d + } + return s } // MaxIntent is how long an intent may be, in characters. @@ -78,6 +226,9 @@ func (r Rule) Conditions() []string { if r.Compact { out = append(out, "compacting") } + if r.Time != nil { + out = append(out, "time "+r.Time.Text()) + } return out } @@ -92,6 +243,16 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) { r.Agents[j] = strings.ToLower(r.Agents[j]) } n := i + 1 + if r.Time != nil { + w := *r.Time + if err := w.clean(); err != nil { + return nil, fmt.Errorf("rule %d: %w", n, err) + } + if w.From == w.To && len(w.Days) == 0 { + return nil, fmt.Errorf("rule %d: its hours are the whole day, every day: give days or other hours", n) + } + r.Time = &w + } switch { case r.Use == "": return nil, fmt.Errorf("rule %d: which model it sends to is missing", n) @@ -104,7 +265,7 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) { case len([]rune(r.Intent)) > MaxIntent: return nil, fmt.Errorf("rule %d: an intent is at most %d characters", n, MaxIntent) case len(r.Conditions()) == 0: - return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents, intent or compacting)", n) + return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents, intent, compacting or time)", n) } if len(r.Agents) == 0 { r.Agents = nil @@ -126,6 +287,9 @@ type RuleRequest struct { Intent string // Compact: the request is the agent compacting its conversation. Compact bool + // At is when the request came, for a rule's hours; a zero time is in + // none. + At time.Time } // Matches reports whether the request is one the rule is for. @@ -148,6 +312,9 @@ func (r Rule) MatchesBesidesIntent(q RuleRequest) bool { if r.Compact && !q.Compact { return false } + if r.Time != nil && !r.Time.Holds(q.At) { + return false + } switch r.Effort { case "": case "on": @@ -244,11 +411,11 @@ func ruledEntry(e *Entry, g Group, ms []Member, entries []Entry) { if !ok { continue } - if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && !r.Compact && sees(x) && !e.Images && + if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && !r.Compact && r.Time == nil && sees(x) && !e.Images && !slices.ContainsFunc(g.Rules[:i], func(b Rule) bool { y, ok := of(b.Use); return !ok || !sees(y) }) { e.Images, e.ImageInput = true, x.ImageInput } - if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || r.Compact || x.Context <= e.Context { + if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || r.Compact || r.Time != nil || x.Context <= e.Context { continue // only a rule of length alone takes every long request } // every request up to the rule's length must fit whoever may get diff --git a/internal/provider/grouprule_time_test.go b/internal/provider/grouprule_time_test.go new file mode 100644 index 00000000..5c20d9d0 --- /dev/null +++ b/internal/provider/grouprule_time_test.go @@ -0,0 +1,151 @@ +package provider + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// at is a local time on the week of Monday 2026-09-28. +func at(day, hour, min int) time.Time { + return time.Date(2026, 9, 27+day, hour, min, 0, 0, time.Local) // day 0 is Sunday +} + +// A rule's hours hold on the machine's clock, past midnight when they end +// before they begin, and on the day they begin on. +func TestRuleTimeWindow(t *testing.T) { + day := TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}} + night := TimeWindow{From: "22:00", To: "08:00", Days: []string{"fri"}} + whole := TimeWindow{From: "00:00", To: "00:00", Days: []string{"sat", "sun"}} + for _, c := range []struct { + w TimeWindow + at time.Time + want bool + }{ + {day, at(1, 9, 0), true}, // Monday, as it opens + {day, at(1, 17, 59), true}, // Monday, before it ends + {day, at(1, 18, 0), false}, // as it ends + {day, at(1, 8, 59), false}, // before + {day, at(6, 12, 0), false}, // Saturday + {night, at(5, 23, 0), true}, // Friday night + {night, at(6, 3, 0), true}, // Saturday's early hours: Friday's window + {night, at(6, 8, 0), false}, // as it ends + {night, at(5, 3, 0), false}, // Friday's early hours: Thursday's window + {night, at(6, 23, 0), false}, + {whole, at(0, 0, 0), true}, + {whole, at(6, 23, 59), true}, + {whole, at(1, 12, 0), false}, + {TimeWindow{From: "22:00", To: "08:00"}, at(3, 7, 0), true}, + {TimeWindow{From: "22:00", To: "08:00"}, at(3, 12, 0), false}, + {day, time.Time{}, false}, // no time is in no hours + } { + if got := c.w.Holds(c.at); got != c.want { + t.Errorf("%s at %s: %v, want %v", c.w.Text(), c.at.Format("Mon 15:04"), got, c.want) + } + } +} + +func TestRuleTimeCleanAndConditions(t *testing.T) { + rules, err := cleanRules([]Rule{ + {Use: "a/x", Time: &TimeWindow{From: "9:00", To: "18:00", Days: []string{"Friday", "mon", "tue", "wed", "thu", "mon"}}}, + {Use: "a/x", Time: &TimeWindow{From: "22:00", To: "08:00"}}, + {Use: "a/x", Time: &TimeWindow{From: "00:00", To: "00:00", Days: []string{"sun", "sat"}}}, + {Use: "a/x", Time: &TimeWindow{From: "08:00", To: "12:00", Days: []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}}}, + {Use: "a/x", Tokens: 100, Time: &TimeWindow{From: "08:00", To: "12:00", Days: []string{"mon", "wed", "thu", "fri"}}}, + }, []string{"a/x"}) + if err != nil { + t.Fatal(err) + } + want := []string{"time 09:00–18:00 Mon–Fri", "time 22:00–08:00", "time all day Sat,Sun", "time 08:00–12:00", "tokens ≥ 100 · time 08:00–12:00 Mon,Wed–Fri"} + for i, r := range rules { + if got := strings.Join(r.Conditions(), " · "); got != want[i] { + t.Errorf("rule %d: %q, want %q", i+1, got, want[i]) + } + } + if d := rules[0].Time.Days; strings.Join(d, ",") != "mon,tue,wed,thu,fri" || rules[0].Time.From != "09:00" { + t.Errorf("cleaned: %+v", rules[0].Time) + } + if rules[3].Time.Days != nil { + t.Errorf("every day is no days: %+v", rules[3].Time) + } + for _, bad := range []TimeWindow{ + {From: "25:00", To: "08:00"}, + {From: "09:00", To: "8"}, + {From: "", To: "08:00"}, + {From: "09:60", To: "10:00"}, + {From: "09:00", To: "18:00", Days: []string{"funday"}}, + {From: "10:00", To: "10:00"}, // the whole day, every day: no condition + } { + if _, err := cleanRules([]Rule{{Use: "a/x", Time: &bad}}, []string{"a/x"}); err == nil { + t.Errorf("%+v: no error", bad) + } + } +} + +// A rule with hours matches a request inside them only; one without a +// time (an older magpie's) is in none. +func TestRuleTimeMatches(t *testing.T) { + r := Rule{Use: "a/x", Time: &TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}} + if !r.Matches(RuleRequest{At: at(2, 10, 0)}) { + t.Error("inside the hours: no match") + } + if r.Matches(RuleRequest{At: at(2, 19, 0)}) || r.Matches(RuleRequest{At: at(0, 10, 0)}) || r.Matches(RuleRequest{}) { + t.Error("outside the hours: a match") + } + if i := MatchRule([]Rule{r, {Use: "a/y", Tokens: 10}}, RuleRequest{Tokens: 20, At: at(2, 20, 0)}); i != 1 { + t.Errorf("after hours: rule %d", i) + } +} + +// Stored with omitempty: a rule without hours is written as before, and a +// window round-trips. +func TestRuleTimeJSON(t *testing.T) { + b, _ := json.Marshal(Rule{Use: "a/x", Tokens: 5}) + if strings.Contains(string(b), "time") { + t.Errorf("no hours written: %s", b) + } + b, _ = json.Marshal(Rule{Use: "a/x", Time: &TimeWindow{From: "22:00", To: "08:00"}}) + if string(b) != `{"use":"a/x","time":{"from":"22:00","to":"08:00"}}` { + t.Errorf("%s", b) + } + var r Rule + if err := json.Unmarshal(b, &r); err != nil || r.Time == nil || r.Time.From != "22:00" { + t.Errorf("%v %+v", err, r) + } +} + +// Typed: time=… days=… — read back the same. +func TestParseRuleTime(t *testing.T) { + g := Group{ID: "g", Members: []string{"a/x", "b/y"}} + for _, c := range []struct{ line, want, cond string }{ + {"use=b/y time=09:00-18:00 days=mon-fri", "use=b/y time=09:00-18:00 days=mon-fri", "time 09:00–18:00 Mon–Fri"}, + {"use=b/y days=sat,sun", "use=b/y days=sat,sun", "time all day Sat,Sun"}, + {"use=b/y days=fri-mon time=22:00–08:00", "use=b/y time=22:00-08:00 days=mon,fri-sun", "time 22:00–08:00 Mon,Fri–Sun"}, + {"use=b/y time=22:00-08:00", "use=b/y time=22:00-08:00", "time 22:00–08:00"}, + } { + r, _, _, err := ParseRule(g, RuleWords(c.line)) + if err != nil { + t.Fatalf("%s: %v", c.line, err) + } + rs, err := cleanRules([]Rule{r}, g.Members) + if err != nil { + t.Fatalf("%s: %v", c.line, err) + } + if got := rs[0].Line(); got != c.want { + t.Errorf("%s: line %q, want %q", c.line, got, c.want) + } + if got := strings.Join(rs[0].Conditions(), " · "); got != c.cond { + t.Errorf("%s: %q, want %q", c.line, got, c.cond) + } + again, _, _, err := ParseRule(g, RuleWords(rs[0].Line())) + if err != nil || again.Time == nil || again.Time.From != rs[0].Time.From || again.Time.To != rs[0].Time.To { + t.Errorf("%s: read back %v %+v", c.line, err, again.Time) + } + } + for _, bad := range []string{"use=b/y time=9-18", "use=b/y time=09:00", "use=b/y days=someday", "use=b/y days="} { + if _, _, _, err := ParseRule(g, RuleWords(bad)); err == nil { + t.Errorf("%s: no error", bad) + } + } +} diff --git a/internal/provider/ruleparse.go b/internal/provider/ruleparse.go index 663200de..1c7c5f13 100644 --- a/internal/provider/ruleparse.go +++ b/internal/provider/ruleparse.go @@ -1,10 +1,12 @@ package provider // A rule as it is typed: use= tokens=200k images effort=high -// agents=a,b intent="…" compact — by magpie group rule and the TUI's routing page. +// agents=a,b intent="…" compact time=09:00-18:00 days=mon-fri — by magpie +// group rule and the TUI's routing page. import ( "fmt" + "slices" "strconv" "strings" ) @@ -26,6 +28,50 @@ func ParseTokens(v string) (int, error) { return int(f * mul), nil } +// ParseDays reads days of the week: "mon-fri", "sat,sun", "mon,wed-fri"; +// a range may run past Sunday (fri-mon). +func ParseDays(v string) ([]string, error) { + var out []string + for _, part := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) { + a, b, isRange := strings.Cut(strings.ReplaceAll(part, "–", "-"), "-") + from, ok := ParseDay(a) + if !ok { + return nil, fmt.Errorf("days: %q is not a day of the week (mon … sun)", a) + } + to := from + if isRange { + if to, ok = ParseDay(b); !ok { + return nil, fmt.Errorf("days: %q is not a day of the week (mon … sun)", b) + } + } + for i := slices.Index(Weekdays, from); ; i = (i + 1) % 7 { + if !slices.Contains(out, Weekdays[i]) { + out = append(out, Weekdays[i]) + } + if Weekdays[i] == to { + break + } + } + } + if len(out) == 0 { + return nil, fmt.Errorf("days names the days of the week it holds on: days=mon-fri") + } + return out, nil +} + +// ParseTimeWindow reads hours of the day: 09:00-18:00, 22:00-08:00. +func ParseTimeWindow(v string) (*TimeWindow, error) { + from, to, ok := strings.Cut(strings.ReplaceAll(strings.TrimSpace(v), "–", "-"), "-") + if !ok { + return nil, fmt.Errorf("time is hours of the day, local time: time=09:00-18:00 (or 22:00-08:00, past midnight), not %q", v) + } + w := &TimeWindow{From: strings.TrimSpace(from), To: strings.TrimSpace(to)} + if err := w.clean(); err != nil { + return nil, err + } + return w, nil +} + // UnknownRuleWord starts the error for a word ParseRule doesn't know. const UnknownRuleWord = "unknown " @@ -79,6 +125,24 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err if r.Intent == "" { return r, 0, "", fmt.Errorf(`intent says what the message asks for: intent="writing or fixing tests"`) } + case "time", "hours", "between": + w, err := ParseTimeWindow(v) + if err != nil { + return r, 0, "", err + } + if r.Time != nil { + w.Days = r.Time.Days + } + r.Time = w + case "days", "day": + days, err := ParseDays(v) + if err != nil { + return r, 0, "", err + } + if r.Time == nil { + r.Time = &TimeWindow{From: "00:00", To: "00:00"} // the whole day, unless time= says + } + r.Time.Days = days case "classifier", "classify", "by": if classifier = strings.TrimSpace(v); classifier == "" { return r, 0, "", fmt.Errorf("classifier=: the model that tells which intent a message is") @@ -90,7 +154,7 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err } at = n default: - return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, compact, classifier, at)", w) + return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, compact, time, days, classifier, at)", w) } } if r.Use == "" { @@ -183,5 +247,13 @@ func (r Rule) Line() string { if r.Compact { out = append(out, "compact") } + if r.Time != nil { + if r.Time.From != r.Time.To || len(r.Time.Days) == 0 { + out = append(out, "time="+r.Time.From+"-"+r.Time.To) + } + if len(r.Time.Days) > 0 { + out = append(out, "days="+strings.ReplaceAll(strings.ToLower(r.Time.DaysText()), "–", "-")) + } + } return strings.Join(out, " ") } diff --git a/internal/tui/routing.go b/internal/tui/routing.go index ded40a36..b6ad4c51 100644 --- a/internal/tui/routing.go +++ b/internal/tui/routing.go @@ -263,7 +263,8 @@ func (m *model) openClassifier(g provider.Group) { // ruleHint is what a rule is typed as. const ruleHint = `use=, and any of: tokens=200k · images · effort=on|low|medium|high|xhigh|max · agents=codex,claude -intent="a quick question" (the group's classifier= tells it) · compact · at= for its place` +intent="a quick question" (the group's classifier= tells it) · compact +time=09:00-18:00 (local; 22:00-08:00 runs past midnight) · days=mon-fri · at= for its place` // openRule asks for a rule: a new one, or rule i (from 0) typed again. func (m *model) openRule(g provider.Group, i int) {