diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts index 3ef66f3b5cf3..81ccfa13e93c 100644 --- a/src/agents/compaction.identifier-preservation.test.ts +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -15,6 +15,7 @@ vi.mock("./sessions/index.js", async () => { const mockGenerateSummary = vi.mocked(agentSessions.generateSummary); type SummarizeInStagesInput = Parameters[0]; +const MESSAGE_TIME_BASE_MS = Date.UTC(2026, 0, 1); const { buildCompactionSummarizationInstructions, summarizeInStages } = await import("./compaction.js"); @@ -23,7 +24,7 @@ function makeMessage(index: number, size = 1200): AgentMessage { return { role: "user", content: `m${index}-${"x".repeat(size)}`, - timestamp: index, + timestamp: MESSAGE_TIME_BASE_MS + index * 60_000, }; } @@ -113,6 +114,14 @@ describe("compaction identifier-preservation instructions", () => { "Preserve all opaque identifiers exactly as written", ); } + + type SyntheticMergeMessage = { role: "user"; content: string; timestamp: number }; + const mergeMessages = mockGenerateSummary.mock.calls[2]![0] as SyntheticMergeMessage[]; + expect(mergeMessages.map((message) => message.content)).toEqual([ + "[Chunk 1 — oldest messages [2026-01-01 00:01 — 2026-01-01 00:02 UTC]]\nsummary", + "[Chunk 2 — most recent messages [2026-01-01 00:03 — 2026-01-01 00:04 UTC]]\nsummary", + ]); + expect(mergeMessages[1]!.timestamp).toBe(mergeMessages[0]!.timestamp + 1); }); it("avoids duplicate additional-focus headers in split+merge path", async () => { diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index e672ad9941ad..eb70a2277d7e 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -2,9 +2,9 @@ * Summarization and fallback helpers for transcript compaction. */ import type { AgentCompactionIdentifierPolicy } from "../config/types.agent-defaults.js"; +import { isAbortError } from "../infra/abort-signal.js"; import { formatErrorMessage } from "../infra/errors.js"; import { retryAsync } from "../infra/retry.js"; -import { isAbortError } from "../infra/abort-signal.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { buildOversizedFallbackPlanWithWorker, @@ -338,6 +338,31 @@ export async function summarizeWithFallback(params: { ); } +/** Extracts a compact timestamp range from a chunk of messages for merge metadata. */ +function extractChunkTimeRange(chunk: AgentMessage[]): string { + let earliest: number | undefined; + let latest: number | undefined; + for (const message of chunk) { + const timestamp = message.timestamp; + if ( + typeof timestamp !== "number" || + timestamp <= 0 || + !Number.isFinite(new Date(timestamp).getTime()) + ) { + continue; + } + earliest = earliest === undefined ? timestamp : Math.min(earliest, timestamp); + latest = latest === undefined ? timestamp : Math.max(latest, timestamp); + } + if (earliest === undefined || latest === undefined) { + return ""; + } + const format = (timestamp: number) => + new Date(timestamp).toISOString().replace("T", " ").slice(0, 16); + const range = earliest === latest ? format(earliest) : `${format(earliest)} — ${format(latest)}`; + return ` [${range} UTC]`; +} + /** Summarizes history in multiple stages when a single pass would be too large. */ export async function summarizeInStages(params: { messages: AgentMessage[]; @@ -386,11 +411,28 @@ export async function summarizeInStages(params: { return partialSummaries[0]; } - const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({ - role: "user", - content: summary, - timestamp: Date.now(), - })); + // Capture once so timestamps are strictly monotonic across + // all synthetic messages regardless of how long the map iteration takes. + const now = Date.now(); + const summaryMessages: AgentMessage[] = partialSummaries.map((summary, index) => { + // serializeConversation preserves content but not timestamps, so chronology + // must be explicit in the text consumed by the merge model. + const chunk = plan.chunks[index]; + const timeRange = extractChunkTimeRange(chunk); + const label = + index === 0 + ? `[Chunk 1 — oldest messages${timeRange}]` + : index === partialSummaries.length - 1 + ? `[Chunk ${partialSummaries.length} — most recent messages${timeRange}]` + : `[Chunk ${index + 1}/${partialSummaries.length}${timeRange}]`; + return { + role: "user", + content: `${label}\n${summary}`, + // Ascending timestamps preserve chronological order for any code + // path that reads the AgentMessage timestamp field directly. + timestamp: now - (partialSummaries.length - 1 - index), + }; + }); const custom = params.customInstructions?.trim(); const mergeInstructions = custom