diff --git a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts index 3415c22a..6f13335c 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts @@ -11,7 +11,11 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg async insertIfAbsent(input: Parameters[0]) { const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName); - if (existing.some((message) => message.messageId === input.messageId)) { + const existingMessage = existing.find((message) => message.messageId === input.messageId); + if (existingMessage) { + if (existingMessage.workSyncPayloadHash !== input.payloadHash) { + return { inserted: false, messageId: input.messageId, conflict: true }; + } return { inserted: false, messageId: input.messageId }; } @@ -30,6 +34,7 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg workSyncIntent: input.payload.workSyncIntent, workSyncIntentKey: input.payload.workSyncIntentKey, workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds, + workSyncPayloadHash: input.payloadHash, }); return { diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 2e952c96..1e60b846 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -159,6 +159,8 @@ export class TeamInboxReader { (id): id is string => typeof id === 'string' && id.length > 0 ) : undefined, + workSyncPayloadHash: + typeof row.workSyncPayloadHash === 'string' ? row.workSyncPayloadHash : undefined, slashCommand: row.slashCommand && typeof row.slashCommand === 'object' && diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index addae1b6..63326acf 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -77,6 +77,7 @@ export class TeamInboxWriter { ...(request.workSyncReviewRequestEventIds?.length ? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds } : {}), + ...(request.workSyncPayloadHash ? { workSyncPayloadHash: request.workSyncPayloadHash } : {}), ...(request.slashCommand && { slashCommand: request.slashCommand }), ...(request.commandOutput && { commandOutput: request.commandOutput }), }; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 1abf4fd4..1feac3b9 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -670,6 +670,8 @@ export interface InboxMessage { workSyncIntentKey?: string; /** Concrete review_requested event IDs covered by this nudge. */ workSyncReviewRequestEventIds?: string[]; + /** Durable hash for idempotent hidden member-work-sync automation rows. */ + workSyncPayloadHash?: string; /** Structured slash-command metadata for sent command rows. */ slashCommand?: SlashCommandMeta; /** Structured command-output metadata for session-derived result rows. */ @@ -717,6 +719,7 @@ export interface SendMessageRequest { workSyncIntent?: InboxMessage['workSyncIntent']; workSyncIntentKey?: string; workSyncReviewRequestEventIds?: string[]; + workSyncPayloadHash?: string; slashCommand?: SlashCommandMeta; commandOutput?: CommandOutputMeta; } diff --git a/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts index f250ed4b..021036c9 100644 --- a/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts +++ b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts @@ -31,7 +31,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => { it('returns inserted=false when the inbox already contains the stable messageId', async () => { const input = makeInput(); const inboxReader = { - getMessagesFor: vi.fn(async () => [{ messageId: input.messageId }]), + getMessagesFor: vi.fn(async () => [ + { messageId: input.messageId, workSyncPayloadHash: input.payloadHash }, + ]), }; const inboxWriter = { sendMessage: vi.fn(), @@ -47,6 +49,52 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => { expect(inboxWriter.sendMessage).not.toHaveBeenCalled(); }); + it('fails closed when the existing stable messageId has a different payload hash', async () => { + const input = makeInput(); + const inboxReader = { + getMessagesFor: vi.fn(async () => [ + { messageId: input.messageId, workSyncPayloadHash: 'different-payload-hash' }, + ]), + }; + const inboxWriter = { + sendMessage: vi.fn(), + }; + const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never); + + await expect(sink.insertIfAbsent(input)).resolves.toEqual({ + inserted: false, + messageId: input.messageId, + conflict: true, + }); + + expect(inboxWriter.sendMessage).not.toHaveBeenCalled(); + }); + + it('treats legacy work-sync rows without payload hash as conflicts', async () => { + const input = makeInput(); + const inboxReader = { + getMessagesFor: vi.fn(async () => [ + { + messageId: input.messageId, + messageKind: 'member_work_sync_nudge', + workSyncIntent: input.payload.workSyncIntent, + }, + ]), + }; + const inboxWriter = { + sendMessage: vi.fn(), + }; + const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never); + + await expect(sink.insertIfAbsent(input)).resolves.toEqual({ + inserted: false, + messageId: input.messageId, + conflict: true, + }); + + expect(inboxWriter.sendMessage).not.toHaveBeenCalled(); + }); + it('writes a system notification inbox message for a new nudge', async () => { const input = makeInput(); const inboxReader = { @@ -77,6 +125,7 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => { workSyncIntent: 'agenda_sync', workSyncIntentKey: undefined, workSyncReviewRequestEventIds: undefined, + workSyncPayloadHash: input.payloadHash, }); }); diff --git a/test/main/services/team/TeamInboxReader.test.ts b/test/main/services/team/TeamInboxReader.test.ts index c4a5ef7f..61622e54 100644 --- a/test/main/services/team/TeamInboxReader.test.ts +++ b/test/main/services/team/TeamInboxReader.test.ts @@ -177,6 +177,35 @@ describe('TeamInboxReader', () => { }); }); + it('preserves member-work-sync payload hash without changing visible message fields', async () => { + hoisted.files.set( + '/mock/teams/my-team/inboxes/alice.json', + JSON.stringify([ + { + from: 'system', + to: 'alice', + text: 'Please reconcile current work.', + timestamp: '2026-01-01T02:30:00.000Z', + read: false, + messageId: 'member-work-sync:my-team:alice:agenda', + source: 'system_notification', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + workSyncPayloadHash: 'sha256:work-sync', + }, + ]) + ); + + const messages = await reader.getMessagesFor('my-team', 'alice'); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + messageId: 'member-work-sync:my-team:alice:agenda', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + workSyncPayloadHash: 'sha256:work-sync', + }); + }); + it('preserves agent error semantic kind from the team lead inbox', async () => { hoisted.files.set( '/mock/teams/my-team/inboxes/team-lead.json', diff --git a/test/main/services/team/TeamInboxWriter.test.ts b/test/main/services/team/TeamInboxWriter.test.ts index 08bf1468..6d24dccb 100644 --- a/test/main/services/team/TeamInboxWriter.test.ts +++ b/test/main/services/team/TeamInboxWriter.test.ts @@ -157,6 +157,25 @@ describe('TeamInboxWriter', () => { expect(persisted[0].source).toBe('system_notification'); }); + it('persists member-work-sync payload hash when provided', async () => { + await writer.sendMessage('my-team', { + member: 'alice', + text: 'sync your work state', + source: 'system_notification', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + workSyncPayloadHash: 'sha256:work-sync', + }); + + const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record[]; + expect(persisted).toHaveLength(1); + expect(persisted[0]).toMatchObject({ + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + workSyncPayloadHash: 'sha256:work-sync', + }); + }); + it('preserves provided message identity fields for dedup across live and persisted rows', async () => { const result = await writer.sendMessage('my-team', { member: 'alice',