fix(core): inject current date on every user query to prevent stale date (#4798)

* fix(core): inject current date on every user query to prevent stale date

The date in the startup context is injected once at session start and never
refreshed, causing the model to report outdated time during long-running
conversations.

Inject the current date as a system reminder on every UserQuery turn,
modeled after Cline's approach of re-resolving CURRENT_DATE at send time.
Uses a lastInjectedDate cache to avoid injecting duplicate dates within
the same session (prevents conflicting dates when a session spans midnight).

- packages/core/src/core/client.ts: add lastInjectedDate field, inject
  date only on UserQuery turns and only when the date has changed
- packages/core/src/core/client.test.ts: fix 3 existing tests that now
  expect a date prefix, add 2 new tests for date injection and dedup

Signed-off-by: Alex <alex.tech.lab@outlook.com>

* fix(core): address review feedback - pin locale and fix timer cleanup

- Pin toLocaleDateString to 'en-US' for consistent output across locales
- Replace vi.setSystemTime(undefined) with vi.useRealTimers() to fix TS2345

Signed-off-by: Alex <alex.tech.lab@outlook.com>

* fix(core): address R3 review feedback - system-reminder tags, shared date format, session reset, midnight test

- Wrap date reminder in <system-reminder> tags (consistent with other reminders)
- Extract formatDateForContext() shared function in environmentContext.ts
- Use shared function in both client.ts and environmentContext.ts (pin 'en-US')
- Reset lastInjectedDate in startChat() for session boundary correctness
- Add midnight rollover test (date changes trigger re-injection)
- Update 3 existing tests that asserted not.toContain('<system-reminder>')
  to check for IDE-specific content instead (date reminder now uses tags)
- Update mock to use importOriginal so formatDateForContext is real

Signed-off-by: Alex <alex.tech.lab@outlook.com>

* fix(core): address R4 review feedback - update startup context text and fix TS2835

- Remove '(formatted according to the user's locale)' from startup context
  template since formatDateForContext now hardcodes 'en-US'
- Remove corresponding test assertion in environmentContext.test.ts
- Add missing .js extension in typeof import() to fix TS2835

Signed-off-by: Alex <alex.tech.lab@outlook.com>

* fix(core): address R5 review feedback - centralize timer cleanup and fix timezone edge case

- Add vi.useRealTimers() to top-level afterEach to prevent fake timer leaks
- Remove per-test vi.useRealTimers() calls from 3 date injection tests
- Change T10:00:00Z to T12:00:00Z in date tests to avoid timezone edge cases
  (10:00 UTC could be next day in UTC+14)

Signed-off-by: Alex <alex.tech.lab@outlook.com>

* fix(core): address R6 review feedback - timezone, Cron test, resetChat test, formatDateForContext test

- Set process.env.TZ=UTC in client.test.ts for consistent toLocaleDateString
  output regardless of developer timezone
- Add Cron non-injection negative test (date not injected on Cron turns)
- Add resetChat() clearing lastInjectedDate test
- Add formatDateForContext() unit tests (en-US locale contract)
- Fix Cron test: pass { type: SendMessageType.Cron } not SendMessageType.Cron

Signed-off-by: Alex <alex.tech.lab@outlook.com>

---------

Signed-off-by: Alex <alex.tech.lab@outlook.com>
This commit is contained in:
AlexHuang 2026-06-08 20:23:44 +08:00 committed by GitHub
parent 27b056b777
commit 6a99ed1500
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 334 additions and 13 deletions

View file

@ -14,6 +14,10 @@ import {
type Mock,
} from 'vitest';
// Force UTC timezone so toLocaleDateString('en-US', ...) produces consistent
// output regardless of the developer's local timezone.
process.env.TZ = 'UTC';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -151,6 +155,27 @@ vi.mock('../utils/gitUtils.js', async (importOriginal) => {
vi.mock('../utils/nextSpeakerChecker', () => ({
checkNextSpeaker: vi.fn().mockResolvedValue(null),
}));
vi.mock('../utils/environmentContext', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/environmentContext.js')>();
return {
...actual,
getEnvironmentContext: vi
.fn()
.mockResolvedValue([{ text: 'Mocked env context' }]),
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
{
role: 'user',
parts: [{ text: 'Mocked env context' }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
...(extraHistory ?? []),
]),
};
});
vi.mock('../utils/environmentContext', () => ({
SYSTEM_REMINDER_OPEN: '<system-reminder>',
getEnvironmentContext: vi
@ -484,6 +509,7 @@ describe('Gemini Client (client.ts)', () => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
__resetActiveGoalStoreForTests();
});
@ -1534,6 +1560,12 @@ describe('Gemini Client (client.ts)', () => {
expect(hookSystem.fireSessionStartEvent).toHaveBeenCalledTimes(1);
});
it('should reset lastInjectedDate', async () => {
client['lastInjectedDate'] = 'Friday, June 5, 2026';
await client.resetChat();
expect(client['lastInjectedDate']).toBeUndefined();
});
});
describe('history mutation invalidates FileReadCache', () => {
@ -2754,7 +2786,10 @@ Other open files:
expect(mockChat.addHistory).not.toHaveBeenCalled();
expect(mockTurnRunFn).toHaveBeenCalledWith(
'test-model',
[`<system-reminder>\n${expectedContext}\n</system-reminder>\n\nHi`],
[
expect.stringMatching(/^<system-reminder>\nThe current date is:/),
`<system-reminder>\n${expectedContext}\n</system-reminder>\n\nHi`,
],
expect.any(AbortSignal),
);
});
@ -3460,7 +3495,10 @@ hello
// The main request should have been called without any memory content
expect(mockTurnRunFn).toHaveBeenCalledWith(
'test-model',
['Quick question'],
[
expect.stringMatching(/^<system-reminder>\nThe current date is:/),
'Quick question',
],
expect.any(AbortSignal),
);
@ -3496,7 +3534,10 @@ hello
// The main request should have been called without any memory content
expect(mockTurnRunFn).toHaveBeenCalledWith(
'test-model',
['Quick question'],
[
expect.stringMatching(/^<system-reminder>\nThe current date is:/),
'Quick question',
],
expect.any(AbortSignal),
);
});
@ -3553,6 +3594,231 @@ hello
});
});
it('should inject the current date on every UserQuery turn', async () => {
client['lastInjectedDate'] = undefined;
vi.setSystemTime(new Date('2026-06-05T12:00:00Z'));
const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
})();
mockTurnRunFn.mockReturnValue(mockStream);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const stream = client.sendMessageStream(
[{ text: 'What day is it?' }],
new AbortController().signal,
'prompt-id-date-inject',
);
for await (const _ of stream) {
// consume stream
}
// The first element in the request should be the date reminder
// wrapped in <system-reminder> tags
expect(mockTurnRunFn).toHaveBeenCalledWith(
'test-model',
[
expect.stringMatching(
/^<system-reminder>\nThe current date is:.*June 5, 2026/,
),
'What day is it?',
],
expect.any(AbortSignal),
);
});
it('should not inject duplicate date on the same day', async () => {
client['lastInjectedDate'] = undefined;
vi.setSystemTime(new Date('2026-06-05T12:00:00Z'));
const mockStream1 = (async function* () {
yield { type: 'content', value: 'Hello' };
})();
mockTurnRunFn.mockReturnValue(mockStream1);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
// First query on June 5 — should inject date
const stream1 = client.sendMessageStream(
[{ text: 'First question' }],
new AbortController().signal,
'prompt-id-date-first',
);
for await (const _ of stream1) {
// consume stream
}
expect(mockTurnRunFn).toHaveBeenLastCalledWith(
'test-model',
[
expect.stringMatching(
/^<system-reminder>\nThe current date is:.*June 5, 2026/,
),
'First question',
],
expect.any(AbortSignal),
);
// Second query same day — should NOT inject date again
const mockStream2 = (async function* () {
yield { type: 'content', value: 'World' };
})();
mockTurnRunFn.mockReturnValue(mockStream2);
mockChat.getHistory = vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: 'First question' }] },
{ role: 'model', parts: [{ text: 'Hello' }] },
]);
const stream2 = client.sendMessageStream(
[{ text: 'Second question' }],
new AbortController().signal,
'prompt-id-date-second',
);
for await (const _ of stream2) {
// consume stream
}
// Second call should NOT have date prefix (already injected today)
const secondCall = mockTurnRunFn.mock.calls[1];
expect(secondCall[1][0]).toBe('Second question');
});
it('should re-inject date when session spans midnight', async () => {
client['lastInjectedDate'] = undefined;
vi.setSystemTime(new Date('2026-06-04T12:00:00Z'));
const mockStream1 = (async function* () {
yield { type: 'content', value: 'Hello' };
})();
mockTurnRunFn.mockReturnValue(mockStream1);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
// First query on June 4 — should inject date
const stream1 = client.sendMessageStream(
[{ text: 'Day one' }],
new AbortController().signal,
'prompt-id-date-day-one',
);
for await (const _ of stream1) {
// consume stream
}
expect(mockTurnRunFn).toHaveBeenLastCalledWith(
'test-model',
[
expect.stringMatching(
/^<system-reminder>\nThe current date is:.*June 4, 2026/,
),
'Day one',
],
expect.any(AbortSignal),
);
// Advance to June 5 — date should change
vi.setSystemTime(new Date('2026-06-05T12:00:00Z'));
const mockStream2 = (async function* () {
yield { type: 'content', value: 'New day' };
})();
mockTurnRunFn.mockReturnValue(mockStream2);
mockChat.getHistory = vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: 'Day one' }] },
{ role: 'model', parts: [{ text: 'Hello' }] },
]);
const stream2 = client.sendMessageStream(
[{ text: 'Day two' }],
new AbortController().signal,
'prompt-id-date-day-two',
);
for await (const _ of stream2) {
// consume stream
}
// New date should be injected with June 5
const secondCall = mockTurnRunFn.mock.calls[1];
expect(secondCall[1][0]).toMatch(
/^<system-reminder>\nThe current date is:.*June 5, 2026/,
);
});
it('should not inject date on Cron turns', async () => {
client['lastInjectedDate'] = undefined;
vi.setSystemTime(new Date('2026-06-05T12:00:00Z'));
const mockStream = (async function* () {
yield { type: 'content', value: 'Cron response' };
})();
mockTurnRunFn.mockReturnValue(mockStream);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
// Send a Cron message — date should NOT be injected
const stream = client.sendMessageStream(
[{ text: 'cron-task' }],
new AbortController().signal,
'prompt-id-cron',
{ type: SendMessageType.Cron },
);
for await (const _ of stream) {
// consume stream
}
// Date must NOT be present, but other system reminders (e.g. PlanMode)
// may be included, so check that the date reminder is absent
const cronCall = mockTurnRunFn.mock.calls[0];
const cronRequest = cronCall[1].join('\n');
expect(cronRequest).not.toContain(
'<system-reminder>\nThe current date is:',
);
// UserQuery after Cron should still inject date normally
client['lastInjectedDate'] = undefined;
mockChat.getHistory = vi.fn().mockReturnValue([]);
const mockStream2 = (async function* () {
yield { type: 'content', value: 'Hello' };
})();
mockTurnRunFn.mockReturnValue(mockStream2);
const stream2 = client.sendMessageStream(
[{ text: 'User question' }],
new AbortController().signal,
'prompt-id-cron-user',
);
for await (const _ of stream2) {
// consume stream
}
expect(mockTurnRunFn).toHaveBeenLastCalledWith(
'test-model',
[
expect.stringMatching(
/^<system-reminder>\nThe current date is:.*June 5, 2026/,
),
'User question',
],
expect.any(AbortSignal),
);
});
describe('autoSkill: scheduleSkillReview via runManagedAutoMemoryBackgroundTasks', () => {
let mockStreamFn: () => AsyncGenerator<{ type: string; value: string }>;
let mockChat: Partial<GeminiChat>;
@ -4301,7 +4567,10 @@ Other open files:
expect(getLastTurnRequestText()).toContain('</system-reminder>');
} else {
expect(mockChat.addHistory).not.toHaveBeenCalled();
expect(getLastTurnRequestText()).not.toContain('<system-reminder>');
// Date reminder uses <system-reminder> too, so check for the IDE-specific one
expect(getLastTurnRequestText()).not.toContain(
"Here is a summary of changes in the user's current editor context",
);
}
},
);
@ -4536,7 +4805,10 @@ Other open files:
/* consume */
}
expect(getLastTurnRequestText()).not.toContain('<system-reminder>');
// Date reminder uses <system-reminder> too, so check for IDE-specific one
expect(getLastTurnRequestText()).not.toContain(
"Here is the user's current editor context",
);
expect(client['lastSentIdeContext']).toBeUndefined();
expect(client['forceFullIdeContext']).toBe(true);

View file

@ -78,6 +78,7 @@ import {
// Utilities
import {
formatDateForContext,
buildAddedMcpToolsReminder,
getDirectoryContextString,
getInitialChatHistory,
@ -200,6 +201,14 @@ export class GeminiClient {
private announcedDeferredToolNames = new Set<string>();
private pendingAddedMcpTools = new Map<string, DeferredToolSummary>();
/**
* Tracks the most recently injected date string to prevent injecting
* duplicate or conflicting dates when a session spans midnight.
* Only UserQuery turns inject dates; Cron/ToolResult turns reuse the
* startup-context date which is still current within the same session.
*/
private lastInjectedDate: string | undefined;
/**
* Promises for pending background memory tasks (dream / extract).
* Each promise resolves with a count of memory files touched (0 = nothing written).
@ -855,6 +864,7 @@ export class GeminiClient {
: SessionStartSource.Startup,
): Promise<GeminiChat> {
this.forceFullIdeContext = true;
this.lastInjectedDate = undefined;
// Clear stale cache params on session reset to prevent cross-session leakage
clearCacheSafeParams();
@ -1701,6 +1711,22 @@ export class GeminiClient {
) {
const systemReminders = [];
// Inject fresh date on UserQuery turns only; Cron and ToolResult turns
// reuse the same session and the startup-context date is still current.
if (messageType === SendMessageType.UserQuery) {
const today = formatDateForContext();
// Only inject if the date has changed since the last injection.
// This prevents accumulating conflicting dates when a session
// spans midnight.
if (today !== this.lastInjectedDate) {
systemReminders.push(
`<system-reminder>\nThe current date is: ${today}. Note: This is the authoritative current date — it may differ from the "Today's date" mentioned earlier in the conversation startup context.\n</system-reminder>`,
);
this.lastInjectedDate = today;
}
}
// add plan mode system reminder if approval mode is plan
if (this.config.getApprovalMode() === ApprovalMode.PLAN) {
systemReminders.push(

View file

@ -24,6 +24,7 @@ import {
getStartupContextLength,
isSystemReminderContent,
stripStartupContext,
formatDateForContext,
SYSTEM_REMINDER_OPEN,
SYSTEM_REMINDER_CLOSE,
} from './environmentContext.js';
@ -121,7 +122,6 @@ describe('getEnvironmentContext', () => {
const context = parts[0].text;
expect(context).toContain("Today's date is");
expect(context).toContain("(formatted according to the user's locale)");
expect(context).toContain(`My operating system is: ${process.platform}`);
expect(context).toContain(
"I'm currently working in the directory: /test/dir",
@ -385,6 +385,20 @@ describe('stripStartupContext', () => {
});
});
describe('formatDateForContext', () => {
it('should format date in en-US locale regardless of system timezone', () => {
expect(formatDateForContext(new Date('2026-06-05T12:00:00Z'))).toBe(
'Friday, June 5, 2026',
);
expect(formatDateForContext(new Date('2026-01-01T12:00:00Z'))).toBe(
'Thursday, January 1, 2026',
);
});
it('should use current date when no date provided', () => {
const result = formatDateForContext();
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
describe('startup reminder builders', () => {
function registry(overrides: Partial<ToolRegistry>): ToolRegistry {
return {

View file

@ -18,6 +18,20 @@ export const SYSTEM_REMINDER_OPEN = '<system-reminder>';
export const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
const MAX_DEFERRED_TOOL_DESC_LEN = 160;
/**
* Shared date formatter for system-prompt date injection.
* Pinned to 'en-US' so both the startup context and per-turn
* reminder produce the same format regardless of system locale.
*/
export function formatDateForContext(date: Date = new Date()): string {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
/**
* Generates a string describing the current workspace directories and their structures.
* @param {Config} config - The runtime configuration and services.
@ -60,18 +74,13 @@ ${folderStructure}`;
* @returns A promise that resolves to an array of `Part` objects containing environment information.
*/
export async function getEnvironmentContext(config: Config): Promise<Part[]> {
const today = new Date().toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = formatDateForContext();
const platform = process.platform;
const directoryContext = await getDirectoryContextString(config);
const context = `
This is the Qwen Code. We are setting up the context for our chat.
Today's date is ${today} (formatted according to the user's locale).
Today's date is ${today}.
My operating system is: ${platform}
${directoryContext}
`.trim();