feat(gateway): inject timestamps into agent handler messages
Messages arriving through the gateway agent method (TUI, web, spawned subagents, sessions_send, heartbeats) now get a timestamp prefix automatically. This gives all agent contexts date/time awareness without modifying the system prompt (which is cached for stability). Channel messages (Discord, Telegram, etc.) already have timestamps via envelope formatting in a separate code path and never reach the agent handler, so there is no double-stamping risk. Cron jobs also inject their own 'Current time:' prefix and are detected and skipped. Extracted as a pure function (injectTimestamp) with 12 unit tests covering: timezone handling, 12/24h format, midnight boundaries, envelope detection, cron detection, and empty messages. Integration test verifies the agent handler wires it in correctly. Closes #3658 Refs: #1897, #1928, #2108
This commit is contained in:
parent
c20035094d
commit
cbe388ece3
135
src/gateway/server-methods/agent-timestamp.test.ts
Normal file
135
src/gateway/server-methods/agent-timestamp.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||||
|
|
||||||
|
describe("injectTimestamp", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// Wednesday, January 28, 2026 at 8:30 PM EST
|
||||||
|
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends a formatted timestamp to a plain message", () => {
|
||||||
|
const result = injectTimestamp("Is it the weekend?", {
|
||||||
|
timezone: "America/New_York",
|
||||||
|
timeFormat: "12",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatch(/^\[.+\] Is it the weekend\?$/);
|
||||||
|
expect(result).toContain("Wednesday");
|
||||||
|
expect(result).toContain("January 28");
|
||||||
|
expect(result).toContain("2026");
|
||||||
|
expect(result).toContain("8:30 PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats in 24-hour time when configured", () => {
|
||||||
|
const result = injectTimestamp("hello", {
|
||||||
|
timezone: "America/New_York",
|
||||||
|
timeFormat: "24",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("20:30");
|
||||||
|
expect(result).not.toContain("PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the configured timezone", () => {
|
||||||
|
const result = injectTimestamp("hello", {
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
timeFormat: "12",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8:30 PM EST = 7:30 PM CST
|
||||||
|
expect(result).toContain("7:30 PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to UTC when no timezone specified", () => {
|
||||||
|
const result = injectTimestamp("hello", {});
|
||||||
|
|
||||||
|
// 2026-01-29T01:30:00Z
|
||||||
|
expect(result).toContain("January 29"); // UTC date, not EST
|
||||||
|
expect(result).toContain("1:30 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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",
|
||||||
|
timeFormat: "12",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("February 1");
|
||||||
|
expect(result).toContain("12:00 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles date boundaries (just before midnight)", () => {
|
||||||
|
vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); // 11:59 PM Jan 31 EST
|
||||||
|
|
||||||
|
const result = injectTimestamp("hello", {
|
||||||
|
timezone: "America/New_York",
|
||||||
|
timeFormat: "12",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("January 31");
|
||||||
|
expect(result).toContain("11:59 PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a custom now date", () => {
|
||||||
|
const customDate = new Date("2025-07-04T16:00:00.000Z"); // July 4, noon EST
|
||||||
|
|
||||||
|
const result = injectTimestamp("fireworks?", {
|
||||||
|
timezone: "America/New_York",
|
||||||
|
timeFormat: "12",
|
||||||
|
now: customDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("July 4");
|
||||||
|
expect(result).toContain("2025");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("timestampOptsFromConfig", () => {
|
||||||
|
it("extracts timezone and timeFormat from config", () => {
|
||||||
|
const opts = timestampOptsFromConfig({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
userTimezone: "America/Chicago",
|
||||||
|
timeFormat: "24",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(opts.timezone).toBe("America/Chicago");
|
||||||
|
expect(opts.timeFormat).toBe("24");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back gracefully with empty config", () => {
|
||||||
|
const opts = timestampOptsFromConfig({} as any);
|
||||||
|
|
||||||
|
expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default
|
||||||
|
expect(opts.timeFormat).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/gateway/server-methods/agent-timestamp.ts
Normal file
69
src/gateway/server-methods/agent-timestamp.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
formatUserTime,
|
||||||
|
resolveUserTimeFormat,
|
||||||
|
resolveUserTimezone,
|
||||||
|
} from "../../agents/date-time.js";
|
||||||
|
import type { MoltbotConfig } from "../../config/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envelope pattern used by channel plugins (Discord, Telegram, etc.):
|
||||||
|
* [Channel sender 2026-01-28 20:31 EST] message text
|
||||||
|
*
|
||||||
|
* Messages arriving through channels already have timestamps.
|
||||||
|
* We skip injection for those to avoid double-stamping.
|
||||||
|
*/
|
||||||
|
const ENVELOPE_PATTERN = /^\[[\w]+ .+ \d{4}-\d{2}-\d{2}/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron jobs inject "Current time: ..." into their messages.
|
||||||
|
* Skip injection for those too.
|
||||||
|
*/
|
||||||
|
const CRON_TIME_PATTERN = /Current time: /;
|
||||||
|
|
||||||
|
export interface TimestampInjectionOptions {
|
||||||
|
timezone?: string;
|
||||||
|
timeFormat?: "12" | "24";
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a timestamp prefix into a message if one isn't already present.
|
||||||
|
*
|
||||||
|
* Used by the gateway agent handler to give all agent contexts (TUI, web,
|
||||||
|
* spawned subagents, sessions_send, heartbeats) date/time awareness without
|
||||||
|
* modifying the system prompt (which is cached for stability).
|
||||||
|
*
|
||||||
|
* Channel messages (Discord, Telegram, etc.) already have timestamps via
|
||||||
|
* envelope formatting and take a separate code path — they never reach
|
||||||
|
* the agent handler, so there's no double-stamping risk.
|
||||||
|
*
|
||||||
|
* @see https://github.com/moltbot/moltbot/issues/3658
|
||||||
|
*/
|
||||||
|
export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string {
|
||||||
|
if (!message.trim()) return message;
|
||||||
|
|
||||||
|
// Already has a channel envelope timestamp
|
||||||
|
if (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 timeFormat = opts?.timeFormat ?? "12";
|
||||||
|
|
||||||
|
const formatted = formatUserTime(now, timezone, resolveUserTimeFormat(timeFormat));
|
||||||
|
if (!formatted) return message;
|
||||||
|
|
||||||
|
return `[${formatted}] ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build TimestampInjectionOptions from a MoltbotConfig.
|
||||||
|
*/
|
||||||
|
export function timestampOptsFromConfig(cfg: MoltbotConfig): TimestampInjectionOptions {
|
||||||
|
return {
|
||||||
|
timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||||
|
timeFormat: cfg.agents?.defaults?.timeFormat as "12" | "24" | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
updateSessionStore: vi.fn(),
|
updateSessionStore: vi.fn(),
|
||||||
agentCommand: vi.fn(),
|
agentCommand: vi.fn(),
|
||||||
registerAgentRunContext: vi.fn(),
|
registerAgentRunContext: vi.fn(),
|
||||||
|
loadConfigReturn: {} as Record<string, unknown>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../session-utils.js", () => ({
|
vi.mock("../session-utils.js", () => ({
|
||||||
@ -32,7 +33,7 @@ vi.mock("../../commands/agent.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/config.js", () => ({
|
vi.mock("../../config/config.js", () => ({
|
||||||
loadConfig: () => ({}),
|
loadConfig: () => mocks.loadConfigReturn,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
@ -115,6 +116,62 @@ describe("gateway agent handler", () => {
|
|||||||
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
|
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",
|
||||||
|
timeFormat: "12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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).toMatch(
|
||||||
|
/^\[.*Wednesday.*January 28.*2026.*8:30 PM.*\] Is it the weekend\?$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
mocks.loadConfigReturn = {};
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("handles missing cliSessionIds gracefully", async () => {
|
it("handles missing cliSessionIds gracefully", async () => {
|
||||||
mocks.loadSessionEntry.mockReturnValue({
|
mocks.loadSessionEntry.mockReturnValue({
|
||||||
cfg: {},
|
cfg: {},
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
import { listAgentIds } from "../../agents/agent-scope.js";
|
import { listAgentIds } from "../../agents/agent-scope.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveExplicitAgentSessionKey,
|
resolveExplicitAgentSessionKey,
|
||||||
@ -138,6 +139,13 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
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 isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
|
||||||
const channelHints = [request.channel, request.replyChannel]
|
const channelHints = [request.channel, request.replyChannel]
|
||||||
.filter((value): value is string => typeof value === "string")
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user