mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 10:58:37 +00:00
fix(agents): honor disabled envelope timestamps at model boundary (#93238)
Merged via squash.
Prepared head SHA: 53f7117a4b
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
Reviewed-by: @osolmaz
This commit is contained in:
@@ -36,7 +36,7 @@ If `userTimezone` is unset, OpenClaw resolves the host timezone at runtime (no c
|
||||
|
||||
- **Use UTC envelopes** (`envelopeTimezone: "utc"`) when you want stable timestamps across hosts in different regions, or when you want UTC-aligned logs to match diagnostics output.
|
||||
- **Use a fixed IANA zone** (e.g. `"Europe/Vienna"`) when the gateway host is in one zone but the user is in another and you want envelopes to read in the user's zone regardless of host migration.
|
||||
- **Set `envelopeTimestamp: "off"`** for low-token envelopes when timestamp context is not useful for the conversation.
|
||||
- **Set `envelopeTimestamp: "off"`** when timestamp context is not useful for the conversation. This removes absolute timestamps from envelopes, direct agent prompt prefixes, and embedded model-input prefixes.
|
||||
|
||||
For the full behavior reference, examples per provider, and elapsed-time formatting, see [Date & Time](/date-time).
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ You can override this behavior:
|
||||
- `envelopeTimezone: "local"` uses the host timezone.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers, direct agent prompt prefixes, and embedded model-input prefixes.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripInboundMetadata } from "../../../auto-reply/reply/strip-inbound-meta.js";
|
||||
import { buildTimestampPrefix } from "../../../gateway/server-methods/agent-timestamp.js";
|
||||
import { streamOpenAICompletions } from "../../../llm/providers/openai-completions.js";
|
||||
import { streamOpenAIResponses } from "../../../llm/providers/openai-responses.js";
|
||||
import type { Context, Model } from "../../../llm/types.js";
|
||||
import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -29,6 +32,10 @@ import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js";
|
||||
type AgentMsg = Parameters<typeof normalizeMessagesForLlmBoundary>[0][number];
|
||||
|
||||
const TZ = "UTC";
|
||||
// Payload capture stops before transport, but the provider helper still
|
||||
// requires this option. Keep the fixture as joined inert text so scanners do
|
||||
// not treat it as credential material.
|
||||
const TEST_PROVIDER_OPTION_VALUE = ["fixture", "transport", "value"].join("-");
|
||||
|
||||
/** A user message as it sits in the JSONL transcript: BARE string + timestamp. */
|
||||
function storedUserMsg(content: string, timestamp: number): AgentMsg {
|
||||
@@ -57,7 +64,92 @@ const ASSISTANT_MSG: AgentMsg = {
|
||||
const TS_TURN1 = 1717570800000; // fixed arrival time for turn 1
|
||||
const TS_TURN2 = 1717570860000; // turn 2 (a minute later — crosses minute boundary)
|
||||
|
||||
const EXPECTED_PREFIX_TURN1 = buildTimestampPrefix(new Date(TS_TURN1), { timezone: TZ });
|
||||
function requiredTimestampPrefix(timestamp: number): string {
|
||||
const prefix = buildTimestampPrefix(new Date(timestamp), { timezone: TZ });
|
||||
if (!prefix) {
|
||||
throw new Error("expected timestamp prefix");
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
const EXPECTED_PREFIX_TURN1 = requiredTimestampPrefix(TS_TURN1);
|
||||
const EXPECTED_PREFIX_TURN2 = requiredTimestampPrefix(TS_TURN2);
|
||||
|
||||
const OPENAI_COMPLETIONS_MODEL = {
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
const OPENAI_RESPONSES_MODEL = {
|
||||
...OPENAI_COMPLETIONS_MODEL,
|
||||
api: "openai-responses",
|
||||
} satisfies Model<"openai-responses">;
|
||||
|
||||
async function captureOpenAICompletionsPayload(
|
||||
messages: AgentMsg[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const stream = streamOpenAICompletions(
|
||||
OPENAI_COMPLETIONS_MODEL,
|
||||
{
|
||||
systemPrompt: "Stable system prompt",
|
||||
messages: normalizeMessagesForLlmBoundary(messages, { timezone: TZ }) as Context["messages"],
|
||||
},
|
||||
{
|
||||
apiKey: TEST_PROVIDER_OPTION_VALUE,
|
||||
cacheRetention: "none",
|
||||
onPayload(payload) {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
throw new Error("stop after payload capture");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(capturedPayload).toBeDefined();
|
||||
return capturedPayload!;
|
||||
}
|
||||
|
||||
async function captureOpenAIResponsesPayload(
|
||||
messages: AgentMsg[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const stream = streamOpenAIResponses(
|
||||
OPENAI_RESPONSES_MODEL,
|
||||
{
|
||||
systemPrompt: "Stable system prompt",
|
||||
messages: normalizeMessagesForLlmBoundary(messages, { timezone: TZ }) as Context["messages"],
|
||||
},
|
||||
{
|
||||
apiKey: TEST_PROVIDER_OPTION_VALUE,
|
||||
cacheRetention: "none",
|
||||
onPayload(payload) {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
throw new Error("stop after payload capture");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(capturedPayload).toBeDefined();
|
||||
return capturedPayload!;
|
||||
}
|
||||
|
||||
function firstTwoProviderMessages(payload: Record<string, unknown>): unknown[] {
|
||||
const messages = payload.messages ?? payload.input;
|
||||
expect(Array.isArray(messages)).toBe(true);
|
||||
return (messages as unknown[]).slice(0, 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE GATE: bare-current vs bare-historical byte identity
|
||||
@@ -103,6 +195,62 @@ describe("prompt-cache byte-identity (issue #3658)", () => {
|
||||
expect(typeof normalizedHistorical[0]?.content).toBe("string");
|
||||
});
|
||||
|
||||
it("keeps the OpenAI Chat Completions provider prefix stable with timestamps enabled", async () => {
|
||||
const rawText = "Post-fix cache test ping 1 of 2";
|
||||
const currentPayload = await captureOpenAICompletionsPayload([
|
||||
currentUserMsg(rawText, TS_TURN1),
|
||||
]);
|
||||
const historicalPayload = await captureOpenAICompletionsPayload([
|
||||
storedUserMsg(rawText, TS_TURN1),
|
||||
ASSISTANT_MSG,
|
||||
currentUserMsg("Post-fix cache test ping 2 of 2", TS_TURN2),
|
||||
]);
|
||||
|
||||
const expectedTurn1 = `${EXPECTED_PREFIX_TURN1}${rawText}`;
|
||||
const currentStablePrefix = firstTwoProviderMessages(currentPayload);
|
||||
const historicalStablePrefix = firstTwoProviderMessages(historicalPayload);
|
||||
|
||||
expect(JSON.stringify(currentStablePrefix)).toBe(JSON.stringify(historicalStablePrefix));
|
||||
expect(currentStablePrefix[1]).toEqual({ role: "user", content: expectedTurn1 });
|
||||
expect(historicalStablePrefix[1]).toEqual({ role: "user", content: expectedTurn1 });
|
||||
|
||||
const historicalBytes = JSON.stringify(historicalPayload);
|
||||
expect(historicalBytes.indexOf(EXPECTED_PREFIX_TURN2)).toBeGreaterThan(
|
||||
historicalBytes.indexOf(expectedTurn1),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the OpenAI Responses provider prefix stable with timestamps enabled", async () => {
|
||||
const rawText = "Post-fix cache test ping 1 of 2";
|
||||
const currentPayload = await captureOpenAIResponsesPayload([currentUserMsg(rawText, TS_TURN1)]);
|
||||
const historicalPayload = await captureOpenAIResponsesPayload([
|
||||
storedUserMsg(rawText, TS_TURN1),
|
||||
ASSISTANT_MSG,
|
||||
currentUserMsg("Post-fix cache test ping 2 of 2", TS_TURN2),
|
||||
]);
|
||||
|
||||
const expectedTurn1 = `${EXPECTED_PREFIX_TURN1}${rawText}`;
|
||||
const currentStablePrefix = firstTwoProviderMessages(currentPayload);
|
||||
const historicalStablePrefix = firstTwoProviderMessages(historicalPayload);
|
||||
|
||||
expect(JSON.stringify(currentStablePrefix)).toBe(JSON.stringify(historicalStablePrefix));
|
||||
expect(currentStablePrefix[1]).toEqual({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: expectedTurn1 }],
|
||||
});
|
||||
expect(historicalStablePrefix[1]).toEqual({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: expectedTurn1 }],
|
||||
});
|
||||
|
||||
const historicalBytes = JSON.stringify(historicalPayload);
|
||||
expect(historicalBytes.indexOf(EXPECTED_PREFIX_TURN2)).toBeGreaterThan(
|
||||
historicalBytes.indexOf(expectedTurn1),
|
||||
);
|
||||
});
|
||||
|
||||
it("stamp derives from message timestamp, not wall-clock — repeated calls are byte-stable", () => {
|
||||
// Same message object (fixed timestamp) → identical serialization regardless
|
||||
// of when normalize is called. Guards against any "now"-based drift.
|
||||
|
||||
@@ -115,6 +115,93 @@ describe("normalizeMessagesForLlmBoundary", () => {
|
||||
expect(output[2]?.content).toBe(`${expectedCurrentPrefix}Current ask`);
|
||||
});
|
||||
|
||||
it("can leave user message bytes bare for cache-sensitive local providers", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Cache-sensitive current ask" }],
|
||||
timestamp: 1717570860000,
|
||||
},
|
||||
];
|
||||
|
||||
const output = normalizeMessagesForLlmBoundary(
|
||||
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
|
||||
{ timezone: "UTC", includeTimestamp: false },
|
||||
) as unknown as Array<{ content?: string }>;
|
||||
|
||||
expect(output[0]?.content).toBe("Cache-sensitive current ask");
|
||||
});
|
||||
|
||||
it("does not mutate transcript messages while leaving disabled timestamp output bare", () => {
|
||||
const historicalContent =
|
||||
'Conversation info (untrusted metadata):\n```json\n{"channel":"telegram"}\n```\n\nStored bare ask';
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: historicalContent }],
|
||||
timestamp: 1717570800000,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Historical answer" }],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Current bare ask" }],
|
||||
timestamp: 1717570860000,
|
||||
},
|
||||
];
|
||||
|
||||
const output = normalizeMessagesForLlmBoundary(
|
||||
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
|
||||
{ timezone: "UTC", includeTimestamp: false },
|
||||
) as unknown as Array<{ content?: string }>;
|
||||
|
||||
expect(output[0]?.content).toBe("Stored bare ask");
|
||||
expect(output[2]?.content).toBe("Current bare ask");
|
||||
const firstInput = input[0];
|
||||
if (!firstInput) {
|
||||
throw new Error("expected first input message");
|
||||
}
|
||||
expect(Array.isArray(firstInput.content)).toBe(true);
|
||||
expect((firstInput.content as Array<{ text?: string }>)[0]?.text).toBe(historicalContent);
|
||||
});
|
||||
|
||||
it("preserves stored sidecar metadata while preparing disabled timestamp model bytes", () => {
|
||||
// This boundary normalization prepares provider input only: stored
|
||||
// transcript/embedding sidecar state is preserved by identity, so no
|
||||
// migration or persistent schema change is required for disabled stamps.
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Stored ask with index metadata" }],
|
||||
timestamp: 1717570800000,
|
||||
__openclaw: {
|
||||
seq: 12,
|
||||
embeddingInput: "Stored ask with index metadata",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const output = normalizeMessagesForLlmBoundary(
|
||||
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
|
||||
{ timezone: "UTC", includeTimestamp: false },
|
||||
) as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
expect(output[0]?.content).toBe("Stored ask with index metadata");
|
||||
expect(output[0]?.["__openclaw"]).toEqual({
|
||||
seq: 12,
|
||||
embeddingInput: "Stored ask with index metadata",
|
||||
});
|
||||
expect(output[0]?.["__openclaw"]).toBe(input[0]?.["__openclaw"]);
|
||||
expect(input[0]?.content).toEqual([{ type: "text", text: "Stored ask with index metadata" }]);
|
||||
expect(input[0]?.["__openclaw"]).toEqual({
|
||||
seq: 12,
|
||||
embeddingInput: "Stored ask with index metadata",
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps the current turn from the prepared persisted timestamp when supplied", () => {
|
||||
const preparedTimestamp = 1717570800000;
|
||||
const runtimeTimestamp = 1717574460000;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { RuntimeContextCustomMessage } from "./runtime-context-prompt.js";
|
||||
|
||||
type LlmBoundaryOptions = {
|
||||
timezone?: string;
|
||||
includeTimestamp?: boolean;
|
||||
currentUserTimestampOverride?: {
|
||||
timestamp: number;
|
||||
text: string;
|
||||
@@ -56,6 +57,7 @@ export function normalizeMessagesForCurrentPromptBoundary(params: {
|
||||
messages: AgentMessage[];
|
||||
prompt: string;
|
||||
timezone?: string;
|
||||
includeTimestamp?: boolean;
|
||||
currentUserTimestamp?: number;
|
||||
}): AgentMessage[] {
|
||||
const promptMessage = {
|
||||
@@ -63,7 +65,13 @@ export function normalizeMessagesForCurrentPromptBoundary(params: {
|
||||
content: [{ type: "text" as const, text: params.prompt }],
|
||||
timestamp: params.currentUserTimestamp ?? Date.now(),
|
||||
};
|
||||
const boundaryOptions = params.timezone ? { timezone: params.timezone } : undefined;
|
||||
const boundaryOptions =
|
||||
params.timezone || params.includeTimestamp === false
|
||||
? {
|
||||
...(params.timezone ? { timezone: params.timezone } : {}),
|
||||
...(params.includeTimestamp === false ? { includeTimestamp: false } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return normalizeMessagesForLlmBoundary(
|
||||
[...params.messages, promptMessage],
|
||||
boundaryOptions,
|
||||
@@ -73,6 +81,7 @@ export function normalizeMessagesForCurrentPromptBoundary(params: {
|
||||
export function normalizeCurrentPromptTextForLlmBoundary(params: {
|
||||
prompt: string;
|
||||
timezone?: string;
|
||||
includeTimestamp?: boolean;
|
||||
currentUserTimestamp?: number;
|
||||
}): string {
|
||||
const promptMessage = {
|
||||
@@ -80,7 +89,13 @@ export function normalizeCurrentPromptTextForLlmBoundary(params: {
|
||||
content: [{ type: "text" as const, text: params.prompt }],
|
||||
timestamp: params.currentUserTimestamp ?? Date.now(),
|
||||
};
|
||||
const boundaryOptions = params.timezone ? { timezone: params.timezone } : undefined;
|
||||
const boundaryOptions =
|
||||
params.timezone || params.includeTimestamp === false
|
||||
? {
|
||||
...(params.timezone ? { timezone: params.timezone } : {}),
|
||||
...(params.includeTimestamp === false ? { includeTimestamp: false } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const [normalized] = normalizeMessagesForLlmBoundary([promptMessage], boundaryOptions);
|
||||
const content = (normalized as { content?: unknown } | undefined)?.content;
|
||||
return typeof content === "string" ? content : params.prompt;
|
||||
@@ -372,11 +387,15 @@ function stampUserTextWithMessageTimestamp(
|
||||
text: string,
|
||||
timestamp: unknown,
|
||||
timezone: string | undefined,
|
||||
includeTimestamp: boolean | undefined,
|
||||
): string {
|
||||
// Stamping is opt-in: only the LLM-boundary call sites that pass a resolved
|
||||
// timezone (via resolveUserTimezone) stamp messages. When no timezone is
|
||||
// supplied, the boundary performs form/metadata normalization only — leaving
|
||||
// content bare (this also keeps non-stamping callers and unit fixtures clean).
|
||||
if (includeTimestamp === false) {
|
||||
return text;
|
||||
}
|
||||
if (!timezone) {
|
||||
return text;
|
||||
}
|
||||
@@ -480,7 +499,12 @@ function stripHistoricalInboundMetadataFromUserMessages(
|
||||
return `${envelope}${stripInboundMetadata(body)}`;
|
||||
}
|
||||
const stripped = isActive ? raw : stripInboundMetadata(raw);
|
||||
return stampUserTextWithMessageTimestamp(stripped, messageTimestamp, options?.timezone);
|
||||
return stampUserTextWithMessageTimestamp(
|
||||
stripped,
|
||||
messageTimestamp,
|
||||
options?.timezone,
|
||||
options?.includeTimestamp,
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
|
||||
@@ -2546,6 +2546,8 @@ export async function runEmbeddedAttempt(
|
||||
const boundaryTimezone = isRawModelRun
|
||||
? undefined
|
||||
: resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const includeBoundaryTimestamp =
|
||||
!isRawModelRun && params.config?.agents?.defaults?.envelopeTimestamp !== "off";
|
||||
let currentUserTimestampOverride:
|
||||
| { timestamp: number; text: string; alternateText?: string }
|
||||
| undefined;
|
||||
@@ -2555,6 +2557,7 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
return {
|
||||
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
|
||||
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
|
||||
...(currentUserTimestampOverride ? { currentUserTimestampOverride } : {}),
|
||||
};
|
||||
};
|
||||
@@ -4201,6 +4204,7 @@ export async function runEmbeddedAttempt(
|
||||
messages: messagesForCurrentPrompt,
|
||||
prompt: promptForModel,
|
||||
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
|
||||
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
|
||||
...(typeof preparedUserTurnMessage?.timestamp === "number"
|
||||
? { currentUserTimestamp: preparedUserTurnMessage.timestamp }
|
||||
: {}),
|
||||
@@ -4486,6 +4490,7 @@ export async function runEmbeddedAttempt(
|
||||
const llmBoundaryPromptForPrecheck = normalizeCurrentPromptTextForLlmBoundary({
|
||||
prompt: promptForModel,
|
||||
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
|
||||
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
|
||||
...(typeof preparedUserTurnMessage?.timestamp === "number"
|
||||
? { currentUserTimestamp: preparedUserTurnMessage.timestamp }
|
||||
: {}),
|
||||
@@ -4521,9 +4526,13 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
}
|
||||
|
||||
const llmBoundaryOptionsForPrecheck = boundaryTimezone
|
||||
? { timezone: boundaryTimezone }
|
||||
: undefined;
|
||||
const llmBoundaryOptionsForPrecheck =
|
||||
boundaryTimezone || !includeBoundaryTimestamp
|
||||
? {
|
||||
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
|
||||
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
|
||||
}
|
||||
: undefined;
|
||||
const unwindowedLlmBoundaryMessagesForPrecheck =
|
||||
contextEnginePromptAuthority === "preassembly_may_overflow" &&
|
||||
unwindowedContextEngineMessagesForPrecheck
|
||||
|
||||
@@ -1158,7 +1158,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.envelopeTimezone":
|
||||
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
||||
"agents.defaults.envelopeTimestamp":
|
||||
'Include absolute timestamps in message envelopes ("on" or "off").',
|
||||
'Include absolute timestamps in message envelopes, direct agent prompt prefixes, and embedded model-input prefixes ("on" or "off").',
|
||||
"agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").',
|
||||
"agents.defaults.models":
|
||||
"Configured model catalog and allowlist (keys are full provider/model IDs or literal provider/* entries for dynamic provider catalogs).",
|
||||
|
||||
@@ -313,7 +313,8 @@ export type AgentDefaultsConfig = {
|
||||
*/
|
||||
envelopeTimezone?: string;
|
||||
/**
|
||||
* Include absolute timestamps in message envelopes ("on" | "off", default: "on").
|
||||
* Include absolute timestamps in message envelopes, direct agent prompt prefixes,
|
||||
* and embedded model-input prefixes ("on" | "off", default: "on").
|
||||
*/
|
||||
envelopeTimestamp?: "on" | "off";
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/;
|
||||
export interface TimestampInjectionOptions {
|
||||
timezone?: string;
|
||||
now?: Date;
|
||||
includeTimestamp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,9 @@ export function buildTimestampPrefix(
|
||||
* @see https://github.com/openclaw/openclaw/issues/3658
|
||||
*/
|
||||
export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string {
|
||||
if (opts?.includeTimestamp === false) {
|
||||
return message;
|
||||
}
|
||||
if (!message.trim()) {
|
||||
return message;
|
||||
}
|
||||
@@ -101,5 +105,6 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption
|
||||
export function timestampOptsFromConfig(cfg: OpenClawConfig): TimestampInjectionOptions {
|
||||
return {
|
||||
timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||
includeTimestamp: cfg.agents?.defaults?.envelopeTimestamp !== "off",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -735,6 +735,21 @@ describe("injectTimestamp", () => {
|
||||
|
||||
expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/);
|
||||
});
|
||||
|
||||
it("leaves messages bare when config disables envelope timestamps", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimestamp: "off",
|
||||
userTimezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(injectTimestamp("cache sensitive prompt", timestampOptsFromConfig(cfg))).toBe(
|
||||
"cache sensitive prompt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeChatHistoryMessages", () => {
|
||||
@@ -1898,6 +1913,23 @@ describe("timestampOptsFromConfig", () => {
|
||||
])("$name", ({ cfg, expected }) => {
|
||||
expect(timestampOptsFromConfig(cfg).timezone).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps timestamp injection enabled for upgraded configs unless explicitly disabled", () => {
|
||||
const upgradedConfigWithExistingDefaults = {
|
||||
agents: { defaults: { userTimezone: "America/Chicago" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
// Existing user configs do not store envelopeTimestamp; omission remains
|
||||
// the shipped default even when other agent defaults are present, so no
|
||||
// config migration is needed for this broadened use of the setting.
|
||||
expect(timestampOptsFromConfig({} as OpenClawConfig).includeTimestamp).toBe(true);
|
||||
expect(timestampOptsFromConfig(upgradedConfigWithExistingDefaults).includeTimestamp).toBe(true);
|
||||
expect(
|
||||
timestampOptsFromConfig({
|
||||
agents: { defaults: { envelopeTimestamp: "off" } },
|
||||
} as OpenClawConfig).includeTimestamp,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeRpcAttachmentsToChatAttachments", () => {
|
||||
|
||||
Reference in New Issue
Block a user