routing: a group's rule can hold hours of the day (Discord: send DeepSeek's or GLM's peak-price hours to another provider) — a rule takes time: {from, to, days} in this computer's local time, HH:MM to HH:MM; hours that end before they begin run past midnight and belong to the day they begin on (Friday's 22:00–08:00 takes Saturday's early hours), days are optional, and the hours are checked as a turn begins, so a turn that began inside them finishes where it went. The rule reads "time 09:00–18:00 Mon–Fri" on the group and in the trace; the editor types the hours into one pill (从 … 到 …), notes past midnight, marks a time that isn't one and picks days like the agents (every day, weekdays, weekends, or each); magpie group rule add … time=14:00-18:00 days=mon-fri and the TUI's rule line take it too. Older versions ignore the field, and a rule with only hours then matches nothing there. TestRuleTimeMatches and TestRuleTimeWindowAtTurnStart (injected clock) and routing-time.test.cjs fail without the change. A sandbox magpie with two fake vendors sent a request inside the hours to the rule's member and one outside them, or on another day, to the first. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; routing-time, routing-compact, routing-manual, routing-steady, settings-groups, click-scroll, old-webkit pass.

This commit is contained in:
yetone committed 2026-10-01 15:04:16 +08:00
1 parent 15fa17f1c4
commit 6bf1c3987f
12 files changed
+773 -15

No files matched your search

+7 -1
View File
@@ -15,7 +15,8 @@ import (
const ruleUsage = `usage:
magpie group rule <group> the group's rules
magpie group rule add <group> use=<model> [tokens=<n>] [images] [effort=on|low|medium|high|xhigh|max] [agents=a,b…]
[intent="<what the message asks for>"] [compact] [classifier=<model>] [at=<n>]
[intent="<what the message asks for>"] [compact] [time=HH:MM-HH:MM] [days=mon-fri]
[classifier=<model>] [at=<n>]
a rule: a turn that matches it goes to <model>, one of the group's
(or group/<id>, 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.
+6 -2
View File
@@ -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()
+64
View File
@@ -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())
}
}
+20
View File
@@ -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};对话本身仍留在原来的模型上。",
+5
View File
@@ -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; }
+98 -4
View File
@@ -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");
+11 -1
View File
@@ -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
+164
View File
@@ -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, []);
});
}
}
+171 -4
View File
@@ -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
+151
View File
@@ -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)
}
}
}
+74 -2
View File
@@ -1,10 +1,12 @@
package provider
// A rule as it is typed: use=<model> 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=<model>: 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, " ")
}
+2 -1
View File
@@ -263,7 +263,8 @@ func (m *model) openClassifier(g provider.Group) {
// ruleHint is what a rule is typed as.
const ruleHint = `use=<model>, and any of: tokens=200k · images · effort=on|low|medium|high|xhigh|max · agents=codex,claude
intent="a quick question" (the group's classifier=<model> tells it) · compact · at=<n> for its place`
intent="a quick question" (the group's classifier=<model> tells it) · compact
time=09:00-18:00 (local; 22:00-08:00 runs past midnight) · days=mon-fri · at=<n> 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) {