mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 19:51:22 +00:00
feat(date-time): standardize time context and tool timestamps
This commit is contained in:
@@ -9,6 +9,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
|
||||
@@ -69,44 +70,6 @@ export type CliOutput = {
|
||||
usage?: CliUsage;
|
||||
};
|
||||
|
||||
function resolveUserTimezone(configured?: string): string {
|
||||
const trimmed = configured?.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
|
||||
return trimmed;
|
||||
} catch {
|
||||
// ignore invalid timezone
|
||||
}
|
||||
}
|
||||
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return host?.trim() || "UTC";
|
||||
}
|
||||
|
||||
function formatUserTime(date: Date, timeZone: string): string | undefined {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
}
|
||||
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
|
||||
return undefined;
|
||||
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelAliasLines(cfg?: ClawdbotConfig) {
|
||||
const models = cfg?.agents?.defaults?.models ?? {};
|
||||
const entries: Array<{ alias: string; model: string }> = [];
|
||||
@@ -134,7 +97,8 @@ export function buildSystemPrompt(params: {
|
||||
modelDisplay: string;
|
||||
}) {
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
defaultThinkLevel: params.defaultThinkLevel,
|
||||
@@ -153,6 +117,7 @@ export function buildSystemPrompt(params: {
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles: params.contextFiles,
|
||||
});
|
||||
}
|
||||
|
||||
164
src/agents/date-time.ts
Normal file
164
src/agents/date-time.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export type TimeFormatPreference = "auto" | "12" | "24";
|
||||
export type ResolvedTimeFormat = "12" | "24";
|
||||
|
||||
let cachedTimeFormat: ResolvedTimeFormat | undefined;
|
||||
|
||||
export function resolveUserTimezone(configured?: string): string {
|
||||
const trimmed = configured?.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
|
||||
return trimmed;
|
||||
} catch {
|
||||
// ignore invalid timezone
|
||||
}
|
||||
}
|
||||
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return host?.trim() || "UTC";
|
||||
}
|
||||
|
||||
export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat {
|
||||
if (preference === "12" || preference === "24") return preference;
|
||||
if (cachedTimeFormat) return cachedTimeFormat;
|
||||
cachedTimeFormat = detectSystemTimeFormat() ? "24" : "12";
|
||||
return cachedTimeFormat;
|
||||
}
|
||||
|
||||
export function normalizeTimestamp(
|
||||
raw: unknown,
|
||||
): { timestampMs: number; timestampUtc: string } | undefined {
|
||||
if (raw == null) return undefined;
|
||||
let timestampMs: number | undefined;
|
||||
|
||||
if (raw instanceof Date) {
|
||||
timestampMs = raw.getTime();
|
||||
} else if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
timestampMs = raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
|
||||
} else if (typeof raw === "string") {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) {
|
||||
if (trimmed.includes(".")) {
|
||||
timestampMs = Math.round(num * 1000);
|
||||
} else if (trimmed.length >= 13) {
|
||||
timestampMs = Math.round(num);
|
||||
} else {
|
||||
timestampMs = Math.round(num * 1000);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) timestampMs = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (timestampMs === undefined || !Number.isFinite(timestampMs)) return undefined;
|
||||
return { timestampMs, timestampUtc: new Date(timestampMs).toISOString() };
|
||||
}
|
||||
|
||||
export function withNormalizedTimestamp<T extends Record<string, unknown>>(
|
||||
value: T,
|
||||
rawTimestamp: unknown,
|
||||
): T & { timestampMs?: number; timestampUtc?: string } {
|
||||
const normalized = normalizeTimestamp(rawTimestamp);
|
||||
if (!normalized) return value;
|
||||
return {
|
||||
...value,
|
||||
timestampMs:
|
||||
typeof value.timestampMs === "number" && Number.isFinite(value.timestampMs)
|
||||
? value.timestampMs
|
||||
: normalized.timestampMs,
|
||||
timestampUtc:
|
||||
typeof value.timestampUtc === "string" && value.timestampUtc.trim()
|
||||
? value.timestampUtc
|
||||
: normalized.timestampUtc,
|
||||
};
|
||||
}
|
||||
|
||||
function detectSystemTimeFormat(): boolean {
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
const result = execSync("defaults read -g AppleICUForce24HourTime 2>/dev/null", {
|
||||
encoding: "utf8",
|
||||
timeout: 500,
|
||||
}).trim();
|
||||
if (result === "1") return true;
|
||||
if (result === "0") return false;
|
||||
} catch {
|
||||
// Not set, fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const result = execSync(
|
||||
'powershell -Command "(Get-Culture).DateTimeFormat.ShortTimePattern"',
|
||||
{ encoding: "utf8", timeout: 1000 },
|
||||
).trim();
|
||||
if (result.startsWith("H")) return true;
|
||||
if (result.startsWith("h")) return false;
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sample = new Date(2000, 0, 1, 13, 0);
|
||||
const formatted = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).format(sample);
|
||||
return formatted.includes("13");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ordinalSuffix(day: number): string {
|
||||
if (day >= 11 && day <= 13) return "th";
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return "st";
|
||||
case 2:
|
||||
return "nd";
|
||||
case 3:
|
||||
return "rd";
|
||||
default:
|
||||
return "th";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatUserTime(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
format: ResolvedTimeFormat,
|
||||
): string | undefined {
|
||||
const use24Hour = format === "24";
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: use24Hour ? "2-digit" : "numeric",
|
||||
minute: "2-digit",
|
||||
hourCycle: use24Hour ? "h23" : "h12",
|
||||
}).formatToParts(date);
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
}
|
||||
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
|
||||
return undefined;
|
||||
const dayNum = parseInt(map.day, 10);
|
||||
const suffix = ordinalSuffix(dayNum);
|
||||
const timePart = use24Hour
|
||||
? `${map.hour}:${map.minute}`
|
||||
: `${map.hour}:${map.minute} ${map.dayPeriod ?? ""}`.trim();
|
||||
return `${map.weekday}, ${map.month} ${dayNum}${suffix}, ${map.year} — ${timePart}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,11 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager
|
||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js";
|
||||
import { splitSdkTools } from "./tool-split.js";
|
||||
import type { EmbeddedPiCompactResult } from "./types.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import {
|
||||
describeUnknownError,
|
||||
formatUserTime,
|
||||
mapThinkingLevel,
|
||||
resolveExecToolDefaults,
|
||||
resolveUserTimezone,
|
||||
} from "./utils.js";
|
||||
|
||||
export async function compactEmbeddedPiSession(params: {
|
||||
@@ -228,7 +227,10 @@ export async function compactEmbeddedPiSession(params: {
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const reasoningTagHint = isReasoningTagProvider(provider);
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(
|
||||
params.config?.agents?.defaults?.timeFormat,
|
||||
);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
@@ -251,6 +253,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles,
|
||||
});
|
||||
const systemPrompt = createSystemPromptOverride(appendPrompt);
|
||||
|
||||
@@ -58,12 +58,8 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
||||
import { splitSdkTools } from "../tool-split.js";
|
||||
import {
|
||||
formatUserTime,
|
||||
mapThinkingLevel,
|
||||
resolveExecToolDefaults,
|
||||
resolveUserTimezone,
|
||||
} from "../utils.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
|
||||
import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||
|
||||
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
||||
@@ -186,7 +182,8 @@ export async function runEmbeddedAttempt(
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
@@ -211,6 +208,7 @@ export async function runEmbeddedAttempt(
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles,
|
||||
});
|
||||
const systemPromptReport = buildSystemPromptReport({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ResolvedTimeFormat } from "../date-time.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
import { buildToolSummaryMap } from "../tool-summaries.js";
|
||||
@@ -33,6 +34,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
modelAliasLines: string[];
|
||||
userTimezone: string;
|
||||
userTime?: string;
|
||||
userTimeFormat?: ResolvedTimeFormat;
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
}): string {
|
||||
return buildAgentSystemPrompt({
|
||||
@@ -52,6 +54,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
modelAliasLines: params.modelAliasLines,
|
||||
userTimezone: params.userTimezone,
|
||||
userTime: params.userTime,
|
||||
userTimeFormat: params.userTimeFormat,
|
||||
contextFiles: params.contextFiles,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,45 +17,6 @@ export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaul
|
||||
return { ...tools.bash, ...tools.exec };
|
||||
}
|
||||
|
||||
export function resolveUserTimezone(configured?: string): string {
|
||||
const trimmed = configured?.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
|
||||
return trimmed;
|
||||
} catch {
|
||||
// ignore invalid timezone
|
||||
}
|
||||
}
|
||||
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return host?.trim() || "UTC";
|
||||
}
|
||||
|
||||
export function formatUserTime(date: Date, timeZone: string): string | undefined {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
}
|
||||
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) {
|
||||
return undefined;
|
||||
}
|
||||
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeUnknownError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
|
||||
@@ -71,15 +71,42 @@ describe("buildAgentSystemPrompt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes user time when provided", () => {
|
||||
it("includes user time when provided (12-hour)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
userTime: "Monday 2026-01-05 15:26",
|
||||
userTime: "Monday, January 5th, 2026 — 3:26 PM",
|
||||
userTimeFormat: "12",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
|
||||
expect(prompt).toContain("Time format: 12-hour");
|
||||
});
|
||||
|
||||
it("includes user time when provided (24-hour)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
userTime: "Monday, January 5th, 2026 — 15:26",
|
||||
userTimeFormat: "24",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
|
||||
expect(prompt).toContain("Time format: 24-hour");
|
||||
});
|
||||
|
||||
it("shows UTC fallback when only timezone is provided", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
userTimeFormat: "24",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain(
|
||||
"Time: assume UTC unless stated. User time zone: America/Chicago. Current user time (local, 24-hour): Monday 2026-01-05 15:26 (America/Chicago).",
|
||||
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
||||
import type { ResolvedTimeFormat } from "./date-time.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
|
||||
export function buildAgentSystemPrompt(params: {
|
||||
@@ -15,6 +16,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
modelAliasLines?: string[];
|
||||
userTimezone?: string;
|
||||
userTime?: string;
|
||||
userTimeFormat?: ResolvedTimeFormat;
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
skillsPrompt?: string;
|
||||
heartbeatPrompt?: string;
|
||||
@@ -312,17 +314,21 @@ export function buildAgentSystemPrompt(params: {
|
||||
ownerLine ? "## User Identity" : "",
|
||||
ownerLine ?? "",
|
||||
ownerLine ? "" : "",
|
||||
...(userTimezone || userTime
|
||||
? [
|
||||
"## Current Date & Time",
|
||||
userTime
|
||||
? `${userTime} (${userTimezone ?? "unknown"})`
|
||||
: `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`,
|
||||
params.userTimeFormat
|
||||
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
|
||||
: "",
|
||||
"",
|
||||
]
|
||||
: []),
|
||||
"## Workspace Files (injected)",
|
||||
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||
"",
|
||||
userTimezone || userTime
|
||||
? `Time: assume UTC unless stated. User time zone: ${
|
||||
userTimezone ?? "unknown"
|
||||
}. Current user time (local, 24-hour): ${userTime ?? "unknown"} (${
|
||||
userTimezone ?? "unknown"
|
||||
}).`
|
||||
: "",
|
||||
userTimezone || userTime ? "" : "",
|
||||
"## Reply Tags",
|
||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
||||
"- [[reply_to_current]] replies to the triggering message.",
|
||||
|
||||
@@ -27,32 +27,7 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
|
||||
function formatDiscordTimestamp(ts?: string | null): string | undefined {
|
||||
if (!ts) return undefined;
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
|
||||
const yyyy = String(date.getFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
// getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset.
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffsetMinutes = Math.abs(offsetMinutes);
|
||||
const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
|
||||
const offsetM = String(absOffsetMinutes % 60).padStart(2, "0");
|
||||
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const tzSuffix = tz ? `{${tz}}` : "";
|
||||
|
||||
// Compact ISO-like *local* timestamp with minutes precision.
|
||||
// Example: 2025-01-02T03:04-08:00{America/Los_Angeles}
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`;
|
||||
}
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
|
||||
function parseDiscordMessageLink(link: string) {
|
||||
const normalized = link.trim();
|
||||
@@ -76,6 +51,13 @@ export async function handleDiscordMessagingAction(
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const normalizeMessage = (message: unknown) => {
|
||||
if (!message || typeof message !== "object") return message;
|
||||
return withNormalizedTimestamp(
|
||||
message as Record<string, unknown>,
|
||||
(message as { timestamp?: unknown }).timestamp,
|
||||
);
|
||||
};
|
||||
switch (action) {
|
||||
case "react": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
@@ -189,7 +171,13 @@ export async function handleDiscordMessagingAction(
|
||||
);
|
||||
}
|
||||
const message = await fetchMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true, message, guildId, channelId, messageId });
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
message: normalizeMessage(message),
|
||||
guildId,
|
||||
channelId,
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
case "readMessages": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
@@ -207,11 +195,10 @@ export async function handleDiscordMessagingAction(
|
||||
after: readStringParam(params, "after"),
|
||||
around: readStringParam(params, "around"),
|
||||
});
|
||||
const formattedMessages = messages.map((message) => ({
|
||||
...message,
|
||||
timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
|
||||
}));
|
||||
return jsonResult({ ok: true, messages: formattedMessages });
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messages: messages.map((message) => normalizeMessage(message)),
|
||||
});
|
||||
}
|
||||
case "sendMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
@@ -357,7 +344,7 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
const pins = await listPinsDiscord(channelId);
|
||||
return jsonResult({ ok: true, pins });
|
||||
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
||||
}
|
||||
case "searchMessages": {
|
||||
if (!isActionEnabled("search")) {
|
||||
@@ -386,7 +373,23 @@ export async function handleDiscordMessagingAction(
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, results });
|
||||
if (!results || typeof results !== "object") {
|
||||
return jsonResult({ ok: true, results });
|
||||
}
|
||||
const resultsRecord = results as Record<string, unknown>;
|
||||
const messages = resultsRecord.messages;
|
||||
const normalizedMessages = Array.isArray(messages)
|
||||
? messages.map((group) =>
|
||||
Array.isArray(group) ? group.map((msg) => normalizeMessage(msg)) : group,
|
||||
)
|
||||
: messages;
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
results: {
|
||||
...resultsRecord,
|
||||
messages: normalizedMessages,
|
||||
},
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
|
||||
@@ -17,6 +17,7 @@ const editChannelDiscord = vi.fn(async () => ({
|
||||
name: "edited",
|
||||
}));
|
||||
const editMessageDiscord = vi.fn(async () => ({}));
|
||||
const fetchMessageDiscord = vi.fn(async () => ({}));
|
||||
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
|
||||
const fetchReactionsDiscord = vi.fn(async () => ({}));
|
||||
const listPinsDiscord = vi.fn(async () => ({}));
|
||||
@@ -42,6 +43,7 @@ vi.mock("../../discord/send.js", () => ({
|
||||
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
|
||||
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
|
||||
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
|
||||
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
|
||||
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
|
||||
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
|
||||
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
|
||||
@@ -134,6 +136,80 @@ describe("handleDiscordMessagingAction", () => {
|
||||
),
|
||||
).rejects.toThrow(/Discord reactions are disabled/);
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to readMessages payloads", async () => {
|
||||
readMessagesDiscord.mockResolvedValueOnce([
|
||||
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
"readMessages",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
);
|
||||
const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> };
|
||||
|
||||
const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
|
||||
expect(payload.messages[0].timestampMs).toBe(expectedMs);
|
||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to fetchMessage payloads", async () => {
|
||||
fetchMessageDiscord.mockResolvedValueOnce({
|
||||
id: "1",
|
||||
timestamp: "2026-01-15T11:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
"fetchMessage",
|
||||
{ guildId: "G1", channelId: "C1", messageId: "M1" },
|
||||
enableAllActions,
|
||||
);
|
||||
const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } };
|
||||
|
||||
const expectedMs = Date.parse("2026-01-15T11:00:00.000Z");
|
||||
expect(payload.message?.timestampMs).toBe(expectedMs);
|
||||
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to listPins payloads", async () => {
|
||||
listPinsDiscord.mockResolvedValueOnce([
|
||||
{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
"listPins",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
);
|
||||
const payload = result.details as { pins: Array<{ timestampMs?: number; timestampUtc?: string }> };
|
||||
|
||||
const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
|
||||
expect(payload.pins[0].timestampMs).toBe(expectedMs);
|
||||
expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to searchMessages payloads", async () => {
|
||||
searchMessagesDiscord.mockResolvedValueOnce({
|
||||
total_results: 1,
|
||||
messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
|
||||
});
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
"searchMessages",
|
||||
{ guildId: "G1", content: "hi" },
|
||||
enableAllActions,
|
||||
);
|
||||
const payload = result.details as {
|
||||
results?: { messages?: Array<Array<{ timestampMs?: number; timestampUtc?: string }>> };
|
||||
};
|
||||
|
||||
const expectedMs = Date.parse("2026-01-15T13:00:00.000Z");
|
||||
expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs);
|
||||
expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe(
|
||||
new Date(expectedMs).toISOString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
|
||||
|
||||
@@ -325,4 +325,38 @@ describe("handleSlackAction", () => {
|
||||
threadTs: "1111111111.111111",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to readMessages payloads", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
readSlackMessages.mockResolvedValueOnce({
|
||||
messages: [{ ts: "1735689600.456", text: "hi" }],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
|
||||
const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> };
|
||||
|
||||
const expectedMs = Math.round(1735689600.456 * 1000);
|
||||
expect(payload.messages[0].timestampMs).toBe(expectedMs);
|
||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to pin payloads", async () => {
|
||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
listSlackPins.mockResolvedValueOnce([
|
||||
{
|
||||
type: "message",
|
||||
message: { ts: "1735689600.789", text: "pinned" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
|
||||
const payload = result.details as {
|
||||
pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>;
|
||||
};
|
||||
|
||||
const expectedMs = Math.round(1735689600.789 * 1000);
|
||||
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
|
||||
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
sendSlackMessage,
|
||||
unpinSlackMessage,
|
||||
} from "../../slack/actions.js";
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
|
||||
|
||||
const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
||||
@@ -197,7 +198,13 @@ export async function handleSlackAction(
|
||||
before: before ?? undefined,
|
||||
after: after ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, ...result });
|
||||
const messages = result.messages.map((message) =>
|
||||
withNormalizedTimestamp(
|
||||
message as Record<string, unknown>,
|
||||
(message as { ts?: unknown }).ts,
|
||||
),
|
||||
);
|
||||
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
|
||||
}
|
||||
default:
|
||||
break;
|
||||
@@ -234,7 +241,16 @@ export async function handleSlackAction(
|
||||
const pins = accountOpts
|
||||
? await listSlackPins(channelId, accountOpts)
|
||||
: await listSlackPins(channelId);
|
||||
return jsonResult({ ok: true, pins });
|
||||
const normalizedPins = pins.map((pin) => {
|
||||
const message = pin.message
|
||||
? withNormalizedTimestamp(
|
||||
pin.message as Record<string, unknown>,
|
||||
(pin.message as { ts?: unknown }).ts,
|
||||
)
|
||||
: pin.message;
|
||||
return message ? { ...pin, message } : pin;
|
||||
});
|
||||
return jsonResult({ ok: true, pins: normalizedPins });
|
||||
}
|
||||
|
||||
if (action === "memberInfo") {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
|
||||
import { prependSystemEvents } from "./session-updates.js";
|
||||
|
||||
describe("prependSystemEvents", () => {
|
||||
it("adds a local timestamp to queued system events", async () => {
|
||||
it("adds a UTC timestamp to queued system events", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timestamp = new Date("2026-01-12T20:19:17");
|
||||
vi.setSystemTime(timestamp);
|
||||
@@ -20,15 +20,7 @@ describe("prependSystemEvents", () => {
|
||||
prefixedBodyBase: "User: hi",
|
||||
});
|
||||
|
||||
const expectedTimestamp = timestamp.toLocaleString("en-US", {
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const expectedTimestamp = "2026-01-12T20:19:17Z";
|
||||
|
||||
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
|
||||
|
||||
|
||||
@@ -25,16 +25,17 @@ export async function prependSystemEvents(params: {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const formatSystemEventTimestamp = (ts: number) =>
|
||||
new Date(ts).toLocaleString("en-US", {
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const formatSystemEventTimestamp = (ts: number) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const sec = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||
};
|
||||
|
||||
const systemLines: string[] = [];
|
||||
const queued = drainSystemEventEntries(params.sessionKey);
|
||||
|
||||
@@ -103,6 +103,8 @@ export type AgentDefaultsConfig = {
|
||||
bootstrapMaxChars?: number;
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
|
||||
timeFormat?: "auto" | "12" | "24";
|
||||
/** Optional display-only context window override (used for % in status UIs). */
|
||||
contextTokens?: number;
|
||||
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
|
||||
|
||||
@@ -41,6 +41,7 @@ export const AgentDefaultsSchema = z
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
|
||||
contextTokens: z.number().int().positive().optional(),
|
||||
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
|
||||
memorySearch: MemorySearchSchema,
|
||||
|
||||
Reference in New Issue
Block a user