diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index a8f6c27286..4ec647ab4a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12650,8 +12650,10 @@ test("commits startup notices once on create and after restored history", async }, }; const limitedToolOutput = limitAgentTimelineItemContent(toolOutput); + let activeNotice = notice; + let lastSession: NotifyingSession | null = null; class NotifyingSession extends TestAgentSession { - readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }]; + readonly initialTimeline = [{ item: activeNotice, timestamp: "2026-09-22T00:00:00.000Z" }]; override async *streamHistory(): AsyncGenerator { yield { type: "timeline", @@ -12670,10 +12672,14 @@ test("commits startup notices once on create and after restored history", async } const client = new (class extends TestAgentClient { override async createSession(config: AgentSessionConfig) { - return new NotifyingSession(config); + const session = new NotifyingSession(config); + lastSession = session; + return session; } override async resumeSession() { - return new NotifyingSession({ provider: "codex", cwd: workdir }); + const session = new NotifyingSession({ provider: "codex", cwd: workdir }); + lastSession = session; + return session; } })(); const store = new RecordingTimelineStore(); @@ -12700,6 +12706,42 @@ test("commits startup notices once on create and after restored history", async created.id, ); expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]); + const followUp: AgentTimelineItem = { + type: "user_message", + text: "after cold restore", + messageId: "msg-after-cold-restore", + }; + lastSession!.pushEvent({ type: "timeline", provider: "codex", item: followUp }); + await manager.reloadAgentSession(created.id); + expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([ + notice, + followUp, + ]); + const v1Notice: AgentTimelineItem = { + type: "notification", + level: "info", + message: "This chat uses OpenCode v1.", + }; + const v2Notice: AgentTimelineItem = { + type: "notification", + level: "info", + message: "This chat uses OpenCode v2.", + }; + activeNotice = v1Notice; + await manager.reloadAgentSession(created.id); + activeNotice = v2Notice; + await manager.reloadAgentSession(created.id); + activeNotice = v1Notice; + await manager.reloadAgentSession(created.id); + await manager.reloadAgentSession(created.id); + expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([ + notice, + followUp, + v1Notice, + v2Notice, + v1Notice, + ]); + activeNotice = notice; const resumed = await manager.resumeAgentFromPersistence({ provider: "codex", sessionId: "existing", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 3022b1e7e6..7b48754b9f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -4029,11 +4029,11 @@ export class AgentManager { // Legacy/imported chats need their existing history before startup rows. await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory); } else { - await this.recordInitialTimelineIfMissing( - managed, - session.initialTimeline, + await this.recordInitialTimelineIfMissing({ + agent: managed, + initialTimeline: session.initialTimeline, durableTimelineHasRows, - ); + }); } this.refreshSessionPersistence(managed); } @@ -4226,26 +4226,30 @@ export class AgentManager { }; } - private async recordInitialTimelineIfMissing( - agent: ActiveManagedAgent, - initialTimeline: NonNullable, - durableTimelineHasRows: boolean, - ): Promise { - const existingItems: AgentTimelineItem[] = [...this.timelineStore.getItems(agent.id)]; - if (existingItems.length === 0 && durableTimelineHasRows && this.durableTimelineStore) { + private async recordInitialTimelineIfMissing(options: { + agent: ActiveManagedAgent; + initialTimeline: NonNullable; + durableTimelineHasRows: boolean; + }): Promise { + const { agent, initialTimeline, durableTimelineHasRows } = options; + const inMemoryRows = this.timelineStore.getRows(agent.id); + const firstInMemorySeq = inMemoryRows[0]?.seq ?? Number.POSITIVE_INFINITY; + const existingItems: AgentTimelineItem[] = []; + if (durableTimelineHasRows && this.durableTimelineStore && firstInMemorySeq > 1) { for (const row of await this.durableTimelineStore.getCommittedRows(agent.id)) { - existingItems.push(row.item); + if (row.seq < firstInMemorySeq) { + existingItems.push(row.item); + } } } + for (const row of inMemoryRows) { + existingItems.push(row.item); + } for (const entry of buildImportedTimelineRows(initialTimeline)) { - const alreadyRecorded = existingItems.some( - (item) => - item.type === "notification" && - entry.item.type === "notification" && - item.level === entry.item.level && - item.message === entry.item.message, - ); - if (alreadyRecorded) { + if ( + entry.item.type === "notification" && + isDuplicateInitialTimelineNotification({ existingItems, incoming: entry.item }) + ) { continue; } this.recordTimeline(agent.id, entry.item, { timestamp: entry.timestamp }); @@ -5995,6 +5999,31 @@ export class AgentManager { } } +function isDuplicateInitialTimelineNotification(input: { + existingItems: readonly AgentTimelineItem[]; + incoming: Extract; +}): boolean { + const { existingItems, incoming } = input; + const isOpenCodeRuntimeVersionNotice = /^This chat uses OpenCode v\d+\.$/.test(incoming.message); + if (isOpenCodeRuntimeVersionNotice) { + const lastVersionNotice = existingItems.findLast( + (item): item is Extract => + item.type === "notification" && /^This chat uses OpenCode v\d+\.$/.test(item.message), + ); + return ( + lastVersionNotice !== undefined && + lastVersionNotice.level === incoming.level && + lastVersionNotice.message === incoming.message + ); + } + return existingItems.some( + (item) => + item.type === "notification" && + item.level === incoming.level && + item.message === incoming.message, + ); +} + function matchesImportableSessionQuery( session: ImportableProviderSession, rawQuery: string | undefined,