fix(team): avoid epoch bootstrap timestamps
This commit is contained in:
parent
08b1de7fa2
commit
0551c9c363
2 changed files with 74 additions and 15 deletions
|
|
@ -85,18 +85,21 @@ function resolveLeadName(config: TeamConfig): string {
|
||||||
return lead?.name?.trim() || 'team-lead';
|
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;
|
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();
|
return new Date(raw).toISOString();
|
||||||
}
|
}
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
const parsed = Date.parse(raw);
|
const parsed = Date.parse(raw);
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
return new Date(parsed).toISOString();
|
return new Date(parsed).toISOString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new Date(0).toISOString();
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSyntheticBootstrapDisplayPrompt(
|
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.`;
|
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 members = Array.isArray(config.members) ? config.members : [];
|
||||||
const leadName = resolveLeadName(config);
|
const leadName = resolveLeadName(config);
|
||||||
const normalizedLeadName = leadName.trim().toLowerCase();
|
const normalizedLeadName = leadName.trim().toLowerCase();
|
||||||
|
|
@ -134,15 +140,20 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
|
||||||
member.name.trim().toLowerCase() !== normalizedLeadName &&
|
member.name.trim().toLowerCase() !== normalizedLeadName &&
|
||||||
member.removedAt == null
|
member.removedAt == null
|
||||||
)
|
)
|
||||||
.map((member) => ({
|
.map((member) => {
|
||||||
from: leadName,
|
const messageId = `bootstrap-start:${config.name}:${member.name}`;
|
||||||
to: member.name,
|
return {
|
||||||
text: buildSyntheticBootstrapDisplayPrompt(config, member),
|
from: leadName,
|
||||||
timestamp: resolveSyntheticBootstrapTimestamp(config, member),
|
to: member.name,
|
||||||
read: true,
|
text: buildSyntheticBootstrapDisplayPrompt(config, member),
|
||||||
source: 'system_notification' as const,
|
timestamp:
|
||||||
messageId: `bootstrap-start:${config.name}:${member.name}`,
|
resolveSyntheticBootstrapTimestamp(config, member) ??
|
||||||
}));
|
fallbackTimestampForMessage(messageId),
|
||||||
|
read: true,
|
||||||
|
source: 'system_notification' as const,
|
||||||
|
messageId,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleTeamMessage(message: InboxMessage): boolean {
|
function isVisibleTeamMessage(message: InboxMessage): boolean {
|
||||||
|
|
@ -429,6 +440,7 @@ export class TeamMessageFeedService {
|
||||||
private readonly dirtyTeams = new Set<string>();
|
private readonly dirtyTeams = new Set<string>();
|
||||||
private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>();
|
private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>();
|
||||||
private readonly generationByTeam = new Map<string, number>();
|
private readonly generationByTeam = new Map<string, number>();
|
||||||
|
private readonly syntheticBootstrapTimestampByMessageId = new Map<string, string>();
|
||||||
|
|
||||||
constructor(private readonly deps: TeamMessageFeedDeps) {}
|
constructor(private readonly deps: TeamMessageFeedDeps) {}
|
||||||
|
|
||||||
|
|
@ -487,6 +499,17 @@ export class TeamMessageFeedService {
|
||||||
return this.generationByTeam.get(teamName) ?? 0;
|
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(
|
private refreshCleanExpiredCacheInBackground(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
cached: TeamMessageFeedCacheEntry,
|
cached: TeamMessageFeedCacheEntry,
|
||||||
|
|
@ -554,7 +577,9 @@ export class TeamMessageFeedService {
|
||||||
const sourceMs = Date.now() - sourceStartedAt;
|
const sourceMs = Date.now() - sourceStartedAt;
|
||||||
|
|
||||||
const normalizeStartedAt = Date.now();
|
const normalizeStartedAt = Date.now();
|
||||||
const syntheticMessages = buildSyntheticBootstrapMessages(config);
|
const syntheticMessages = buildSyntheticBootstrapMessages(config, (messageId) =>
|
||||||
|
this.getSyntheticBootstrapFallbackTimestamp(messageId)
|
||||||
|
);
|
||||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
|
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
|
||||||
isVisibleTeamMessage
|
isVisibleTeamMessage
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,40 @@ describe('TeamMessageFeedService', () => {
|
||||||
expect(feed.messages[0].text).toContain('member_briefing');
|
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 () => {
|
it('does not hide user-authored text just because it resembles an internal prompt', async () => {
|
||||||
const service = new TeamMessageFeedService({
|
const service = new TeamMessageFeedService({
|
||||||
getConfig: vi.fn(async () => config),
|
getConfig: vi.fn(async () => config),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue