Merge b603d9c6ba into 09be5d45d5
This commit is contained in:
commit
0ae799b326
@ -97,7 +97,7 @@ function formatUtcTimestamp(date: Date): string {
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||
}
|
||||
|
||||
function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
||||
export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
|
||||
141
src/gateway/server-methods/agent-timestamp.test.ts
Normal file
141
src/gateway/server-methods/agent-timestamp.test.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
||||
|
||||
describe("injectTimestamp", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Wednesday, January 28, 2026 at 8:30 PM EST (01:30 UTC Jan 29)
|
||||
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("prepends a compact timestamp matching formatZonedTimestamp", () => {
|
||||
const result = injectTimestamp("Is it the weekend?", {
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
|
||||
expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/);
|
||||
});
|
||||
|
||||
it("uses channel envelope format with DOW prefix", () => {
|
||||
const now = new Date();
|
||||
const expected = formatZonedTimestamp(now, "America/New_York");
|
||||
|
||||
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||
|
||||
// DOW prefix + formatZonedTimestamp format
|
||||
expect(result).toBe(`[Wed ${expected}] hello`);
|
||||
});
|
||||
|
||||
it("always uses 24-hour format", () => {
|
||||
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||
|
||||
expect(result).toContain("20:30");
|
||||
expect(result).not.toContain("PM");
|
||||
expect(result).not.toContain("AM");
|
||||
});
|
||||
|
||||
it("uses the configured timezone", () => {
|
||||
const result = injectTimestamp("hello", { timezone: "America/Chicago" });
|
||||
|
||||
// 8:30 PM EST = 7:30 PM CST = 19:30
|
||||
expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/);
|
||||
});
|
||||
|
||||
it("defaults to UTC when no timezone specified", () => {
|
||||
const result = injectTimestamp("hello", {});
|
||||
|
||||
// 2026-01-29T01:30:00Z
|
||||
expect(result).toMatch(/^\[Thu 2026-01-29 01:30/);
|
||||
});
|
||||
|
||||
it("returns empty/whitespace messages unchanged", () => {
|
||||
expect(injectTimestamp("", { timezone: "UTC" })).toBe("");
|
||||
expect(injectTimestamp(" ", { timezone: "UTC" })).toBe(" ");
|
||||
});
|
||||
|
||||
it("does NOT double-stamp messages with channel envelope timestamps", () => {
|
||||
const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there";
|
||||
const result = injectTimestamp(enveloped, { timezone: "America/New_York" });
|
||||
|
||||
expect(result).toBe(enveloped);
|
||||
});
|
||||
|
||||
it("does NOT double-stamp messages already injected by us", () => {
|
||||
const alreadyStamped = "[Wed 2026-01-28 20:30 EST] hello there";
|
||||
const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" });
|
||||
|
||||
expect(result).toBe(alreadyStamped);
|
||||
});
|
||||
|
||||
it("does NOT double-stamp messages with cron-injected timestamps", () => {
|
||||
const cronMessage =
|
||||
"[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)";
|
||||
const result = injectTimestamp(cronMessage, { timezone: "America/New_York" });
|
||||
|
||||
expect(result).toBe(cronMessage);
|
||||
});
|
||||
|
||||
it("handles midnight correctly", () => {
|
||||
vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); // midnight EST
|
||||
|
||||
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||
|
||||
expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/);
|
||||
});
|
||||
|
||||
it("handles date boundaries (just before midnight)", () => {
|
||||
vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); // 23:59 Jan 31 EST
|
||||
|
||||
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||
|
||||
expect(result).toMatch(/^\[Sat 2026-01-31 23:59 EST\]/);
|
||||
});
|
||||
|
||||
it("handles DST correctly (same UTC hour, different local time)", () => {
|
||||
// EST (winter): UTC-5 → 2026-01-15T05:00Z = midnight Jan 15
|
||||
vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z"));
|
||||
const winter = injectTimestamp("winter", { timezone: "America/New_York" });
|
||||
expect(winter).toMatch(/^\[Thu 2026-01-15 00:00 EST\]/);
|
||||
|
||||
// EDT (summer): UTC-4 → 2026-07-15T04:00Z = midnight Jul 15
|
||||
vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z"));
|
||||
const summer = injectTimestamp("summer", { timezone: "America/New_York" });
|
||||
expect(summer).toMatch(/^\[Wed 2026-07-15 00:00 EDT\]/);
|
||||
});
|
||||
|
||||
it("accepts a custom now date", () => {
|
||||
const customDate = new Date("2025-07-04T16:00:00.000Z"); // July 4, noon ET
|
||||
|
||||
const result = injectTimestamp("fireworks?", {
|
||||
timezone: "America/New_York",
|
||||
now: customDate,
|
||||
});
|
||||
|
||||
expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestampOptsFromConfig", () => {
|
||||
it("extracts timezone from config", () => {
|
||||
const opts = timestampOptsFromConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Chicago",
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(opts.timezone).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("falls back gracefully with empty config", () => {
|
||||
const opts = timestampOptsFromConfig({} as any);
|
||||
|
||||
expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default
|
||||
});
|
||||
});
|
||||
72
src/gateway/server-methods/agent-timestamp.ts
Normal file
72
src/gateway/server-methods/agent-timestamp.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
||||
import type { MoltbotConfig } from "../../config/types.js";
|
||||
|
||||
/**
|
||||
* Cron jobs inject "Current time: ..." into their messages.
|
||||
* Skip injection for those.
|
||||
*/
|
||||
const CRON_TIME_PATTERN = /Current time: /;
|
||||
|
||||
/**
|
||||
* Matches a leading `[... YYYY-MM-DD HH:MM ...]` envelope — either from
|
||||
* channel plugins or from a previous injection. Uses the same YYYY-MM-DD
|
||||
* HH:MM format as {@link formatZonedTimestamp}, so detection stays in sync
|
||||
* with the formatting.
|
||||
*/
|
||||
const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/;
|
||||
|
||||
export interface TimestampInjectionOptions {
|
||||
timezone?: string;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a compact timestamp prefix into a message if one isn't already
|
||||
* present. Uses the same `YYYY-MM-DD HH:MM TZ` format as channel envelope
|
||||
* timestamps ({@link formatZonedTimestamp}), keeping token cost low (~7
|
||||
* tokens) and format consistent across all agent contexts.
|
||||
*
|
||||
* Used by the gateway `agent` and `chat.send` handlers to give TUI, web,
|
||||
* spawned subagents, `sessions_send`, and heartbeat wake events date/time
|
||||
* awareness — without modifying the system prompt (which is cached).
|
||||
*
|
||||
* Channel messages (Discord, Telegram, etc.) already have timestamps via
|
||||
* envelope formatting and take a separate code path — they never reach
|
||||
* these handlers, so there is no double-stamping risk. The detection
|
||||
* pattern is a safety net for edge cases.
|
||||
*
|
||||
* @see https://github.com/moltbot/moltbot/issues/3658
|
||||
*/
|
||||
export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string {
|
||||
if (!message.trim()) return message;
|
||||
|
||||
// Already has an envelope or injected timestamp
|
||||
if (TIMESTAMP_ENVELOPE_PATTERN.test(message)) return message;
|
||||
|
||||
// Already has a cron-injected timestamp
|
||||
if (CRON_TIME_PATTERN.test(message)) return message;
|
||||
|
||||
const now = opts?.now ?? new Date();
|
||||
const timezone = opts?.timezone ?? "UTC";
|
||||
|
||||
const formatted = formatZonedTimestamp(now, timezone);
|
||||
if (!formatted) return message;
|
||||
|
||||
// 3-letter DOW: small models (8B) can't reliably derive day-of-week from
|
||||
// a date, and may treat a bare "Wed" as a typo. Costs ~1 token.
|
||||
const dow = new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short" }).format(
|
||||
now,
|
||||
);
|
||||
|
||||
return `[${dow} ${formatted}] ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build TimestampInjectionOptions from a MoltbotConfig.
|
||||
*/
|
||||
export function timestampOptsFromConfig(cfg: MoltbotConfig): TimestampInjectionOptions {
|
||||
return {
|
||||
timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||
};
|
||||
}
|
||||
@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
updateSessionStore: vi.fn(),
|
||||
agentCommand: vi.fn(),
|
||||
registerAgentRunContext: vi.fn(),
|
||||
loadConfigReturn: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("../session-utils.js", () => ({
|
||||
@ -32,7 +33,7 @@ vi.mock("../../commands/agent.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
loadConfig: () => mocks.loadConfigReturn,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
@ -115,6 +116,59 @@ describe("gateway agent handler", () => {
|
||||
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
|
||||
});
|
||||
|
||||
it("injects a timestamp into the message passed to agentCommand", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); // Wed Jan 28, 8:30 PM EST
|
||||
mocks.agentCommand.mockReset();
|
||||
|
||||
mocks.loadConfigReturn = {
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: mocks.loadConfigReturn,
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "existing-session-id",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
canonicalKey: "agent:main:main",
|
||||
});
|
||||
mocks.updateSessionStore.mockResolvedValue(undefined);
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
|
||||
const respond = vi.fn();
|
||||
await agentHandlers.agent({
|
||||
params: {
|
||||
message: "Is it the weekend?",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey: "test-timestamp-inject",
|
||||
},
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "ts-1", method: "agent" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
// Wait for the async agentCommand call
|
||||
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
|
||||
|
||||
const callArgs = mocks.agentCommand.mock.calls[0][0];
|
||||
expect(callArgs.message).toBe("[Wed 2026-01-28 20:30 EST] Is it the weekend?");
|
||||
|
||||
mocks.loadConfigReturn = {};
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("handles missing cliSessionIds gracefully", async () => {
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
|
||||
@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { agentCommand } from "../../commands/agent.js";
|
||||
import { listAgentIds } from "../../agents/agent-scope.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveExplicitAgentSessionKey,
|
||||
@ -138,6 +139,13 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject timestamp into messages that don't already have one.
|
||||
// Channel messages (Discord, Telegram, etc.) get timestamps via envelope
|
||||
// formatting in a separate code path — they never reach this handler.
|
||||
// See: https://github.com/moltbot/moltbot/issues/3658
|
||||
message = injectTimestamp(message, timestampOptsFromConfig(cfg));
|
||||
|
||||
const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
|
||||
const channelHints = [request.channel, request.replyChannel]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
|
||||
@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
@ -443,9 +444,14 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
|
||||
const clientInfo = client?.connect?.client;
|
||||
// Inject timestamp so agents know the current date/time.
|
||||
// Only BodyForAgent gets the timestamp — Body stays raw for UI display.
|
||||
// See: https://github.com/moltbot/moltbot/issues/3658
|
||||
const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg));
|
||||
|
||||
const ctx: MsgContext = {
|
||||
Body: parsedMessage,
|
||||
BodyForAgent: parsedMessage,
|
||||
BodyForAgent: stampedMessage,
|
||||
BodyForCommands: commandBody,
|
||||
RawBody: parsedMessage,
|
||||
CommandBody: commandBody,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user