From 0551c9c363599eefde2c80c8481ef6139c14405e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 6 Jun 2026 20:47:16 +0300 Subject: [PATCH] fix(team): avoid epoch bootstrap timestamps --- .../services/team/TeamMessageFeedService.ts | 55 ++++++++++++++----- .../team/TeamMessageFeedService.test.ts | 34 ++++++++++++ 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index fd78b910..d314d690 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -85,18 +85,21 @@ function resolveLeadName(config: TeamConfig): string { return lead?.name?.trim() || 'team-lead'; } -function resolveSyntheticBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string { +function resolveSyntheticBootstrapTimestamp( + config: TeamConfig, + member: TeamConfigMember +): string | null { const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt; - if (typeof raw === 'number' && Number.isFinite(raw)) { + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { return new Date(raw).toISOString(); } if (typeof raw === 'string') { const parsed = Date.parse(raw); - if (Number.isFinite(parsed)) { + if (Number.isFinite(parsed) && parsed > 0) { return new Date(parsed).toISOString(); } } - return new Date(0).toISOString(); + return null; } function buildSyntheticBootstrapDisplayPrompt( @@ -122,7 +125,10 @@ Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a del After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`; } -function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] { +function buildSyntheticBootstrapMessages( + config: TeamConfig, + fallbackTimestampForMessage: (messageId: string) => string +): InboxMessage[] { const members = Array.isArray(config.members) ? config.members : []; const leadName = resolveLeadName(config); const normalizedLeadName = leadName.trim().toLowerCase(); @@ -134,15 +140,20 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] { member.name.trim().toLowerCase() !== normalizedLeadName && member.removedAt == null ) - .map((member) => ({ - from: leadName, - to: member.name, - text: buildSyntheticBootstrapDisplayPrompt(config, member), - timestamp: resolveSyntheticBootstrapTimestamp(config, member), - read: true, - source: 'system_notification' as const, - messageId: `bootstrap-start:${config.name}:${member.name}`, - })); + .map((member) => { + const messageId = `bootstrap-start:${config.name}:${member.name}`; + return { + from: leadName, + to: member.name, + text: buildSyntheticBootstrapDisplayPrompt(config, member), + timestamp: + resolveSyntheticBootstrapTimestamp(config, member) ?? + fallbackTimestampForMessage(messageId), + read: true, + source: 'system_notification' as const, + messageId, + }; + }); } function isVisibleTeamMessage(message: InboxMessage): boolean { @@ -429,6 +440,7 @@ export class TeamMessageFeedService { private readonly dirtyTeams = new Set(); private readonly inFlightByTeam = new Map(); private readonly generationByTeam = new Map(); + private readonly syntheticBootstrapTimestampByMessageId = new Map(); constructor(private readonly deps: TeamMessageFeedDeps) {} @@ -487,6 +499,17 @@ export class TeamMessageFeedService { return this.generationByTeam.get(teamName) ?? 0; } + private getSyntheticBootstrapFallbackTimestamp(messageId: string): string { + const existing = this.syntheticBootstrapTimestampByMessageId.get(messageId); + if (existing) { + return existing; + } + + const timestamp = new Date(Date.now()).toISOString(); + this.syntheticBootstrapTimestampByMessageId.set(messageId, timestamp); + return timestamp; + } + private refreshCleanExpiredCacheInBackground( teamName: string, cached: TeamMessageFeedCacheEntry, @@ -554,7 +577,9 @@ export class TeamMessageFeedService { const sourceMs = Date.now() - sourceStartedAt; const normalizeStartedAt = Date.now(); - const syntheticMessages = buildSyntheticBootstrapMessages(config); + const syntheticMessages = buildSyntheticBootstrapMessages(config, (messageId) => + this.getSyntheticBootstrapFallbackTimestamp(messageId) + ); let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter( isVisibleTeamMessage ); diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index 4a3f8a6e..d8e1b611 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -120,6 +120,40 @@ describe('TeamMessageFeedService', () => { expect(feed.messages[0].text).toContain('member_briefing'); }); + it('does not stamp synthetic bootstrap prompts with Unix epoch when config has no join time', async () => { + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => ({ + name: 'opencode-test', + members: [ + { name: 'team-lead', role: 'Lead' }, + { + name: 'alice', + role: 'Developer', + providerId: 'opencode' as const, + model: 'openrouter/big-pickle', + }, + ], + })), + getInboxMessages: vi.fn(async () => []), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const first = await service.getFeed('opencode-test'); + + expect(first.messages).toHaveLength(1); + expect(first.messages[0].messageId).toBe('bootstrap-start:opencode-test:alice'); + expect(first.messages[0].timestamp).toBe('2026-04-19T18:46:40.000Z'); + expect(first.messages[0].timestamp).not.toBe('1970-01-01T00:00:00.000Z'); + + vi.setSystemTime(new Date('2026-04-19T18:47:00.000Z')); + service.invalidate('opencode-test'); + const refreshed = await service.getFeed('opencode-test'); + + expect(refreshed.messages[0].timestamp).toBe(first.messages[0].timestamp); + expect(refreshed.feedRevision).toBe(first.feedRevision); + }); + it('does not hide user-authored text just because it resembles an internal prompt', async () => { const service = new TeamMessageFeedService({ getConfig: vi.fn(async () => config),