fix: address TypeScript integration review feedback

This commit is contained in:
jinli.yl 2026-08-21 11:56:48 +08:00
parent 7e299624f8
commit 4c3bdec430
17 changed files with 208 additions and 82 deletions

View file

@ -12,8 +12,8 @@ selected adapter:
reme start workspace_dir=/absolute/path/to/workspace
```
The default endpoint is `http://127.0.0.1:2333`. All entries support `REME_URL`, or `REME_HOST` plus `REME_PORT`, and an
optional `REME_API_KEY` bearer token.
The default endpoint is `http://127.0.0.1:2333`. All entries support `REME_URL`, or `REME_HOST` plus `REME_PORT`.
ReMe's HTTP service does not use API-key authentication.
## DeepSeek Harness
@ -46,21 +46,21 @@ Configure the bundle by replacing its row in the profile's `cordis.patch.yml`:
On the DSH Web profile, the same fields are available under **Settings → Plugins → Plugin configuration → ReMe
Memory**. Changes are stored in DSH's user settings document and apply to subsequent requests and captures. Changing
the daily dream controls reschedules the next run; changing the guidance language affects newly started sessions.
Deployment-only `apiKey` and `dreamIntervalMs` values remain outside the user-settings section.
The test-only `dreamIntervalMs` value remains outside the user-settings section.
| Option | Default | Meaning |
| --------------------- | ----------------------- | ------------------------------------------- |
| `endpoint` | `http://127.0.0.1:2333` | ReMe HTTP service URL |
| `language` | `en` | Memory guidance language: `en` or `zh` |
| `autoMemoryEnabled` | `true` | Capture completed main-agent turns |
| `autoMemoryInterval` | `5` | Submit after this many completed turns |
| `autoDreamEnabled` | `true` | Enable daily dream maintenance |
| `dreamCron` | `0 23 * * *` | Daily schedule in the DSH process timezone |
| `rootAgentsOnly` | `true` | Exclude subagents from guidance and capture |
| `requestTimeoutMs` | `10000` | Search request timeout |
| `backgroundTimeoutMs` | `3600000` | Automatic-memory and dream timeout |
| `shutdownTimeoutMs` | `5000` | Best-effort shutdown drain budget |
| `timezone` | `Asia/Shanghai` | IANA timezone used for daily batches |
| Option | Default | Meaning |
| --------------------- | ----------------------- | --------------------------------------------- |
| `endpoint` | `http://127.0.0.1:2333` | ReMe HTTP service URL |
| `language` | `en` | Memory guidance language: `en` or `zh` |
| `autoMemoryEnabled` | `true` | Capture completed main-agent turns |
| `autoMemoryInterval` | `5` | Submit after this many completed turns |
| `autoDreamEnabled` | `true` | Enable daily dream maintenance |
| `dreamCron` | `0 23 * * *` | Daily schedule in the workspace timezone |
| `rootAgentsOnly` | `true` | Exclude subagents from guidance and capture |
| `requestTimeoutMs` | `10000` | Search request timeout |
| `backgroundTimeoutMs` | `3600000` | Automatic-memory and dream timeout |
| `shutdownTimeoutMs` | `5000` | Best-effort shutdown drain budget |
| `timezone` | `Asia/Shanghai` | IANA timezone used for batches and scheduling |
The ReMe card reads the service's `health_check` and `status` jobs on demand. It shows the ReMe version, component
health, chunk/index counts, process RSS, and estimated component memory; it can also display the redacted `app_config`
@ -68,10 +68,6 @@ response and trigger one `auto_dream` run. Diagnostics are refreshed when the ca
not polled continuously. The page calls the configured ReMe HTTP endpoint from the local browser, so that service must
remain browser-reachable and allow the DSH origin.
The current ReMe HTTP service does not authenticate its job routes. `apiKey` only adds an `Authorization: Bearer ...`
header for a deployment whose reverse proxy requires it; it is intentionally not exposed or returned by the DSH
settings page.
## OpenClaw
OpenClaw `2026.3.12` or later can install the same package:

View file

@ -8,7 +8,7 @@
reme start workspace_dir=/absolute/path/to/workspace
```
默认服务地址为 `http://127.0.0.1:2333`。所有入口均支持 `REME_URL`,也支持组合使用 `REME_HOST``REME_PORT`;还可以通过 `REME_API_KEY` 配置可选的 Bearer Token
默认服务地址为 `http://127.0.0.1:2333`。所有入口均支持 `REME_URL`,也支持组合使用 `REME_HOST``REME_PORT`。ReMe HTTP 服务不使用 API Key 认证
## DeepSeek Harness
@ -18,7 +18,7 @@ reme start workspace_dir=/absolute/path/to/workspace
dsh plugin --profile web add @agentscope-ai/reme
```
安装后可在 **设置 → 插件 → 插件配置 → ReMe Memory** 中配置服务地址、记忆指引语言、自动记忆、每日记忆整理和超时时间,并查看服务健康状态。`apiKey` 属于部署级密钥,不会显示在设置页面中
安装后可在 **设置 → 插件 → 插件配置 → ReMe Memory** 中配置服务地址、记忆指引语言、自动记忆、每日记忆整理和超时时间,并查看服务健康状态。每日记忆整理和每日批次统一使用配置的 workspace 时区
完整配置项和 `cordis.patch.yml` 示例请参阅[英文文档](./README.md#deepseek-harness)。

View file

@ -8,11 +8,6 @@
"label": "ReMe endpoint",
"placeholder": "http://127.0.0.1:2333"
},
"apiKey": {
"label": "ReMe API key",
"sensitive": true,
"advanced": true
},
"autoRecall": {
"label": "Auto recall"
},
@ -29,7 +24,6 @@
"additionalProperties": false,
"properties": {
"endpoint": { "type": "string" },
"apiKey": { "type": "string" },
"requestTimeoutMs": {
"type": "integer",
"minimum": 1000,

View file

@ -193,9 +193,6 @@ export class ReMeClient {
method: "POST",
headers: {
"Content-Type": "application/json",
...(config.apiKey
? { Authorization: `Bearer ${config.apiKey}` }
: {}),
},
body: JSON.stringify(payload),
signal,

View file

@ -1,7 +1,6 @@
/** Connection settings shared by every TypeScript host adapter. */
export interface ReMeClientConfig {
endpoint: string;
apiKey: string;
requestTimeoutMs: number;
backgroundTimeoutMs: number;
}

View file

@ -1171,7 +1171,6 @@ function isRuntimeSnapshot(value: unknown): value is ReMeRuntimeSnapshot {
function diagnosticClient(settings: ReMeSettings): ReMeClient {
return new ReMeClient({
endpoint: settings.endpoint,
apiKey: "",
requestTimeoutMs: settings.requestTimeoutMs,
backgroundTimeoutMs: settings.backgroundTimeoutMs,
});

View file

@ -9,14 +9,13 @@ export const REME_SETTINGS_NAMESPACE = settingsNamespace("reme-memory");
export const Config = z.object({
endpoint: z.string().description("ReMe HTTP service URL"),
apiKey: z.string().role("secret").description("Optional ReMe bearer token"),
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000),
backgroundTimeoutMs: z.natural().min(1000).max(3600000).default(3600000),
shutdownTimeoutMs: z.natural().min(100).max(60000).default(5000),
autoMemoryEnabled: z.boolean().default(true),
autoMemoryInterval: z.natural().min(1).max(1000).default(5),
autoDreamEnabled: z.boolean().default(true),
dreamCron: z.string().description("Daily cron in the DSH process timezone"),
dreamCron: z.string().description("Daily cron in the workspace timezone"),
dreamHint: z.string().default(""),
dreamIntervalMs: z.natural().max(2147483647).default(0),
rootAgentsOnly: z.boolean().default(true),
@ -40,7 +39,7 @@ export const SettingsConfig: z<ReMeSettings> = z.object({
dreamCron: z
.string()
.required()
.description("Daily cron in the DSH process timezone"),
.description("Daily cron in the workspace timezone"),
dreamHint: z.string().default(""),
rootAgentsOnly: z.boolean().default(true),
language: z.union(["en", "zh"]).default("en"),
@ -53,7 +52,6 @@ export const SettingsConfig: z<ReMeSettings> = z.object({
const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
endpoint: "http://127.0.0.1:2333",
apiKey: "",
requestTimeoutMs: 10000,
backgroundTimeoutMs: 3600000,
shutdownTimeoutMs: 5000,
@ -86,7 +84,6 @@ export function resolveConfig(
...DEFAULT_CONFIG,
...input,
endpoint: input.endpoint || env.REME_URL || `http://${host}:${port}`,
apiKey: input.apiKey || env.REME_API_KEY || "",
dreamCron:
input.dreamCron || env.REME_DSH_DREAM_CRON || DEFAULT_CONFIG.dreamCron,
};
@ -130,17 +127,13 @@ export function resolveConfig(
config.language = config.language === "zh" ? "zh" : "en";
if (!validTimezone(config.timezone))
throw new TypeError(`Invalid ReMe timezone: ${String(config.timezone)}`);
nextDailyRun(config.dreamCron);
nextDailyRun(config.dreamCron, config.timezone);
return config;
}
/** Project the full plugin configuration into its user-editable settings section. */
export function settingsFrom(config: ReMeConfig): ReMeSettings {
const {
apiKey: _apiKey,
dreamIntervalMs: _dreamIntervalMs,
...settings
} = config;
const { dreamIntervalMs: _dreamIntervalMs, ...settings } = config;
return settings;
}
@ -158,7 +151,7 @@ export function validateSettings(settings: ReMeSettings): void {
if (!validTimezone(settings.timezone)) {
throw new TypeError(`Invalid ReMe timezone: ${String(settings.timezone)}`);
}
nextDailyRun(settings.dreamCron);
nextDailyRun(settings.dreamCron, settings.timezone);
}
function assertEndpoint(value: string): void {

View file

@ -24,6 +24,7 @@ export class ReMeRuntime {
readonly states = new Map<string, SessionState>();
private readonly configSource: () => ReMeConfig;
private dreamTimer: ReturnType<typeof setTimeout> | null = null;
private dreamScheduleGeneration = 0;
private dreamTask: Promise<void> | null = null;
private dreamController: AbortController | null = null;
private started = false;
@ -168,7 +169,7 @@ export class ReMeRuntime {
start(): void {
this.started = true;
if (!this.configSource().autoDreamEnabled || this.stopping) return;
this.scheduleDream();
this.scheduleDream(this.dreamScheduleGeneration);
}
/** Apply a changed settings snapshot to pending batching and dream scheduling. */
@ -179,21 +180,29 @@ export class ReMeRuntime {
for (const state of this.states.values()) this.scheduleAutoMemory(state);
}
if (!this.started) return;
this.dreamScheduleGeneration += 1;
if (this.dreamTimer) clearTimeout(this.dreamTimer);
this.dreamTimer = null;
this.nextDreamAt = undefined;
if (config.autoDreamEnabled) this.scheduleDream();
if (config.autoDreamEnabled)
this.scheduleDream(this.dreamScheduleGeneration);
}
private scheduleDream(): void {
private scheduleDream(generation: number): void {
const config = this.configSource();
if (this.stopping || !config.autoDreamEnabled) return;
if (
generation !== this.dreamScheduleGeneration ||
this.stopping ||
!config.autoDreamEnabled
)
return;
let delay: number;
try {
delay =
config.dreamIntervalMs > 0
? config.dreamIntervalMs
: nextDailyRun(config.dreamCron).getTime() - Date.now();
: nextDailyRun(config.dreamCron, config.timezone).getTime() -
Date.now();
} catch (error) {
this.log("warn", "auto_dream_schedule_invalid", {
error: error instanceof Error ? error.message : String(error),
@ -204,7 +213,7 @@ export class ReMeRuntime {
this.dreamTimer = setTimeout(() => {
this.dreamTimer = null;
this.nextDreamAt = undefined;
void this.runDream().finally(() => this.scheduleDream());
void this.runDream().finally(() => this.scheduleDream(generation));
}, delay);
this.dreamTimer.unref?.();
}
@ -289,6 +298,7 @@ export class ReMeRuntime {
async disposeAll(): Promise<void> {
this.stopping = true;
this.started = false;
this.dreamScheduleGeneration += 1;
if (this.dreamTimer) clearTimeout(this.dreamTimer);
this.dreamTimer = null;
this.nextDreamAt = undefined;

View file

@ -1,6 +1,10 @@
const DAILY_CRON = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/;
export function nextDailyRun(cron: string, now = new Date()): Date {
export function nextDailyRun(
cron: string,
timezone: string,
now = new Date(),
): Date {
const match = DAILY_CRON.exec(String(cron || "").trim());
if (!match) {
throw new Error(
@ -11,8 +15,32 @@ export function nextDailyRun(cron: string, now = new Date()): Date {
const hour = Number(match[2]);
if (minute > 59 || hour > 23)
throw new Error("dreamCron contains an invalid hour or minute");
const next = new Date(now.getTime());
next.setHours(hour, minute, 0, 0);
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
return next;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "numeric",
minute: "numeric",
hourCycle: "h23",
});
const next = new Date(now.getTime() - 26 * 60 * 60 * 1000);
next.setUTCSeconds(0, 0);
const scheduledDays = new Set<string>();
for (let checked = 0; checked < 5 * 24 * 60; checked += 1) {
const parts = formatter.formatToParts(next);
const part = (type: Intl.DateTimeFormatPartTypes): string | undefined =>
parts.find((candidate) => candidate.type === type)?.value;
const candidateHour = Number(part("hour"));
const candidateMinute = Number(part("minute"));
if (candidateHour === hour && candidateMinute === minute) {
const day = `${part("year")}-${part("month")}-${part("day")}`;
if (!scheduledDays.has(day)) {
scheduledDays.add(day);
if (next.getTime() > now.getTime()) return next;
}
}
next.setUTCMinutes(next.getUTCMinutes() + 1);
}
throw new Error("dreamCron has no occurrence in the scheduling window");
}

View file

@ -2,7 +2,6 @@ import type { ReMeClientConfig } from "../core/types.js";
export interface ReMeConfigInput {
endpoint?: string;
apiKey?: string;
requestTimeoutMs?: number;
backgroundTimeoutMs?: number;
shutdownTimeoutMs?: number;
@ -33,7 +32,7 @@ export interface ReMeConfig extends ReMeClientConfig {
}
/** ReMe integration fields owned by the DSH user-settings document. */
export type ReMeSettings = Omit<ReMeConfig, "apiKey" | "dreamIntervalMs">;
export type ReMeSettings = Omit<ReMeConfig, "dreamIntervalMs">;
export interface SessionEvent {
type: string;

View file

@ -10,7 +10,6 @@ export interface OpenClawReMeConfig extends ReMeClientConfig {
const DEFAULT_CONFIG: Readonly<OpenClawReMeConfig> = Object.freeze({
endpoint: "http://127.0.0.1:2333",
apiKey: "",
requestTimeoutMs: 5000,
backgroundTimeoutMs: 3600000,
shutdownTimeoutMs: 5000,
@ -26,7 +25,6 @@ export const OPENCLAW_CONFIG_SCHEMA = {
additionalProperties: false,
properties: {
endpoint: { type: "string" },
apiKey: { type: "string" },
requestTimeoutMs: { type: "integer", minimum: 1000, maximum: 120000 },
backgroundTimeoutMs: { type: "integer", minimum: 1000, maximum: 3600000 },
shutdownTimeoutMs: { type: "integer", minimum: 100, maximum: 60000 },
@ -60,7 +58,6 @@ export function resolveOpenClawConfig(
}
return {
endpoint: normalizedEndpoint,
apiKey: stringValue(input.apiKey) || env.REME_API_KEY || "",
requestTimeoutMs: integer(
input.requestTimeoutMs,
1000,

View file

@ -42,9 +42,8 @@ function normalizeMessage(
sessionId: string,
index: number,
): ReMeMessage | null {
const text = messageText(value.content);
if (!text || text.includes('<reme-context source="auto-recall">'))
return null;
const text = stripAutoRecallContext(messageText(value.content));
if (!text) return null;
const nativeId =
typeof value.id === "string" && value.id ? value.id : `${index}\n${text}`;
const createdAt = timestamp(value.created_at ?? value.timestamp);
@ -57,6 +56,15 @@ function normalizeMessage(
};
}
function stripAutoRecallContext(value: string): string {
const opening = '<reme-context source="auto-recall">';
if (!value.startsWith(opening)) return value;
const closing = "</reme-context>";
const end = value.indexOf(closing, opening.length);
if (end === -1) return value;
return value.slice(end + closing.length).trim();
}
function messageText(content: unknown): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";

View file

@ -20,7 +20,6 @@ test("calls ReMe jobs with their native request and response envelopes", async (
endpoint: "http://127.0.0.1:2333",
requestTimeoutMs: 1000,
backgroundTimeoutMs: 1000,
apiKey: "",
});
const result = await client.search("deployment", { limit: 5, minScore: 0 });
assert.equal(result.ok, true);
@ -49,7 +48,6 @@ test("combines caller cancellation with the request timeout", async () => {
endpoint: "http://127.0.0.1:2333",
requestTimeoutMs: 1000,
backgroundTimeoutMs: 1000,
apiKey: "",
});
const controller = new AbortController();
const request = client.search("deployment", { signal: controller.signal });
@ -102,7 +100,6 @@ test("returns typed health, memory status, and redacted server configuration", a
endpoint,
requestTimeoutMs: 1000,
backgroundTimeoutMs: 1000,
apiKey: "",
}));
const health = await client.healthCheck();
endpoint = "http://second.test";
@ -149,7 +146,6 @@ test("lists and loads read-only ReMe workspace files", async () => {
endpoint: "http://127.0.0.1:2333",
requestTimeoutMs: 1000,
backgroundTimeoutMs: 1000,
apiKey: "",
});
const listing = await client.listFiles("daily", { limit: 1 });
const file = await client.loadFile("daily/2026-08-20/session.md");

View file

@ -40,6 +40,10 @@ test("rejects unknown options and invalid IANA timezones", () => {
() => resolveConfig({ timezone: "Mars/Olympus" }, {}),
/Invalid ReMe timezone/,
);
assert.throws(
() => resolveConfig({ apiKey: "unsupported" }, {}),
/Unknown ReMe config option/,
);
});
test("normalizes bounded plugin configuration", () => {
@ -60,10 +64,9 @@ test("normalizes bounded plugin configuration", () => {
assert.equal(config.rootAgentsOnly, false);
});
test("projects the editable DSH settings without deployment-only secrets", async () => {
const base = resolveConfig({ apiKey: "secret", dreamIntervalMs: 5000 }, {});
test("projects editable DSH settings without test-only intervals", async () => {
const base = resolveConfig({ dreamIntervalMs: 5000 }, {});
const settings = settingsFrom(base);
assert.equal("apiKey" in settings, false);
assert.equal("dreamIntervalMs" in settings, false);
const validated = await SettingsConfig["~standard"].validate({
...settings,
@ -71,7 +74,6 @@ test("projects the editable DSH settings without deployment-only secrets", async
});
assert.equal(validated.issues, undefined);
const merged = mergeSettings(base, validated.value);
assert.equal(merged.apiKey, "secret");
assert.equal(merged.searchLimit, 8);
});

View file

@ -22,6 +22,10 @@ test("normalizes OpenClaw configuration and stable session ids", () => {
() => resolveOpenClawConfig({ endpoint: "file:///tmp/reme" }, {}),
/http\(s\)/,
);
assert.throws(
() => resolveOpenClawConfig({ apiKey: "unsupported" }, {}),
/Unknown ReMe config option/,
);
});
test("keeps the runtime schema aligned with the OpenClaw manifest", async () => {
@ -54,6 +58,27 @@ test("captures only the last OpenClaw user and assistant pair", () => {
);
});
test("removes recalled context while preserving the current OpenClaw prompt", () => {
const messages = captureLastTurn(
[
{
role: "user",
content:
'<reme-context source="auto-recall">\nremembered deployment\n</reme-context>\n\nremember blue',
},
{ role: "assistant", content: "noted" },
],
"session",
);
assert.deepEqual(
messages.map((message) => [message.role, message.content[0].text]),
[
["user", "remember blue"],
["assistant", "noted"],
],
);
});
test("registers OpenClaw recall, capture, tool, and shutdown lifecycle", async () => {
const originalFetch = globalThis.fetch;
const calls = [];

View file

@ -302,6 +302,41 @@ test("applies changed batching and dream settings without replacing the runtime"
await runtime.disposeAll();
});
test("keeps one auto-dream schedule when reconfigured during a run", async () => {
let calls = 0;
let releaseFirst;
const firstRequest = new Promise((resolve) => {
releaseFirst = resolve;
});
let config = {
...CONFIG,
autoDreamEnabled: true,
dreamIntervalMs: 20,
shutdownTimeoutMs: 100,
};
const runtime = new ReMeRuntime(
{
async autoDream() {
calls += 1;
if (calls === 1) await firstRequest;
return { ok: true };
},
},
() => config,
silentLogger(),
);
runtime.start();
await delay(25);
config = { ...config };
runtime.reconfigure();
releaseFirst();
await delay(55);
assert.ok(calls >= 2 && calls <= 3, `expected one schedule, got ${calls}`);
await runtime.disposeAll();
});
function completeTurn(runtime, session, turn, seq, time) {
runtime.capture(session, { type: "turn/start", data: { turn } });
runtime.capture(session, {
@ -335,3 +370,7 @@ function completeTurn(runtime, session, turn, seq, time) {
function silentLogger() {
return { debug() {}, warn() {}, log() {} };
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

View file

@ -3,18 +3,62 @@ import test from "node:test";
import { nextDailyRun } from "../dist/dsh/scheduler.js";
test("computes today's or tomorrow's daily dream run", () => {
const before = new Date(2026, 7, 19, 22, 30, 0);
const today = nextDailyRun("0 23 * * *", before);
assert.equal(today.getDate(), 19);
assert.equal(today.getHours(), 23);
const before = new Date("2026-08-19T14:30:00Z");
const today = nextDailyRun("0 23 * * *", "Asia/Shanghai", before);
assert.equal(today.toISOString(), "2026-08-19T15:00:00.000Z");
const after = new Date(2026, 7, 19, 23, 30, 0);
const tomorrow = nextDailyRun("0 23 * * *", after);
assert.equal(tomorrow.getDate(), 20);
assert.equal(tomorrow.getHours(), 23);
const after = new Date("2026-08-19T15:30:00Z");
const tomorrow = nextDailyRun("0 23 * * *", "Asia/Shanghai", after);
assert.equal(tomorrow.toISOString(), "2026-08-20T15:00:00.000Z");
});
test("uses the configured IANA timezone instead of the process timezone", () => {
const now = new Date("2026-08-19T12:00:00Z");
assert.equal(
nextDailyRun("0 23 * * *", "UTC", now).toISOString(),
"2026-08-19T23:00:00.000Z",
);
assert.equal(
nextDailyRun("0 23 * * *", "America/Los_Angeles", now).toISOString(),
"2026-08-20T06:00:00.000Z",
);
});
test("skips a nonexistent local time across daylight-saving changes", () => {
const beforeSpringForward = new Date("2026-03-08T09:00:00Z");
assert.equal(
nextDailyRun(
"30 2 * * *",
"America/Los_Angeles",
beforeSpringForward,
).toISOString(),
"2026-03-09T09:30:00.000Z",
);
});
test("runs only once on a local date when daylight saving time repeats", () => {
const beforeFirstOccurrence = new Date("2026-11-01T07:00:00Z");
assert.equal(
nextDailyRun(
"30 1 * * *",
"America/Los_Angeles",
beforeFirstOccurrence,
).toISOString(),
"2026-11-01T08:30:00.000Z",
);
const afterFirstOccurrence = new Date("2026-11-01T08:45:00Z");
assert.equal(
nextDailyRun(
"30 1 * * *",
"America/Los_Angeles",
afterFirstOccurrence,
).toISOString(),
"2026-11-02T09:30:00.000Z",
);
});
test("rejects unsupported or invalid cron expressions", () => {
assert.throws(() => nextDailyRun("*/5 * * * *"), /daily form/);
assert.throws(() => nextDailyRun("99 23 * * *"), /invalid/);
assert.throws(() => nextDailyRun("*/5 * * * *", "UTC"), /daily form/);
assert.throws(() => nextDailyRun("99 23 * * *", "UTC"), /invalid/);
});