From c32203154269b40f7ef0c3e055b7aac38c0db67b Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 6 Jun 2026 21:19:18 +0300 Subject: [PATCH] fix(member-work-sync): preserve provider metadata when merging members --- .../TeamRuntimeTurnSettledTargetResolver.ts | 29 +-- .../adapters/output/TeamTaskAgendaSource.ts | 30 +-- .../main/adapters/output/mergeTeamMembers.ts | 110 ++++++++ ...amRuntimeTurnSettledTargetResolver.test.ts | 44 +++- .../output/TeamTaskAgendaSource.test.ts | 63 ++++- .../adapters/output/mergeTeamMembers.test.ts | 245 ++++++++++++++++++ 6 files changed, 487 insertions(+), 34 deletions(-) create mode 100644 src/features/member-work-sync/main/adapters/output/mergeTeamMembers.ts create mode 100644 test/features/member-work-sync/main/adapters/output/mergeTeamMembers.test.ts diff --git a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts index 87c31205..1a4dfb29 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts @@ -7,6 +7,8 @@ import path from 'path'; import { isReservedMemberName, normalizeMemberName } from '../../../core/domain'; +import { mergeTeamMembers } from './mergeTeamMembers'; + import type { RuntimeTurnSettledTargetResolution, RuntimeTurnSettledTargetResolverPort, @@ -14,7 +16,7 @@ import type { import type { RuntimeTurnSettledEvent } from '../../../core/domain'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; -import type { TeamMember, TeamSummary } from '@shared/types'; +import type { TeamMember, TeamProviderId, TeamSummary } from '@shared/types'; export interface RuntimeTurnSettledTeamSource { listTeams(): Promise; @@ -38,26 +40,21 @@ function memberKey(member: Pick): string { return normalizeMemberName(member.name); } -function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] { - const byName = new Map(); - for (const member of configMembers) { - const key = memberKey(member); - if (key) { - byName.set(key, member); - } +function providerIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined { + const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : ''; + if (normalized === 'codex-native') { + return 'codex'; } - for (const member of metaMembers) { - const key = memberKey(member); - if (key) { - byName.set(key, { ...byName.get(key), ...member }); - } + if (normalized === 'opencode-cli') { + return 'opencode'; } - return [...byName.values()]; + return undefined; } -function providerForMember(member: TeamMember | undefined): string | undefined { +function providerForMember(member: TeamMember | undefined): TeamProviderId | undefined { return ( normalizeOptionalTeamProviderId(member?.providerId) ?? + providerIdFromBackend(member?.providerBackendId) ?? inferTeamProviderIdFromModel(member?.model) ); } @@ -202,7 +199,7 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT const normalizedTarget = normalizeMemberName(memberName); return ( - mergeMembers(config.members ?? [], metaMembers).find( + mergeTeamMembers(config.members ?? [], metaMembers).find( (member) => !member.removedAt && memberKey(member) === normalizedTarget ) ?? null ); diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts index 5550c5d5..cadd50ec 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -10,6 +10,8 @@ import { normalizeMemberName, } from '../../../core/domain'; +import { mergeTeamMembers } from './mergeTeamMembers'; + import type { MemberWorkSyncAgendaSourcePort, MemberWorkSyncAgendaSourceResult, @@ -19,7 +21,7 @@ import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; -import type { TeamMember } from '@shared/types'; +import type { TeamMember, TeamProviderId } from '@shared/types'; export interface TeamTaskAgendaSourceDeps { configReader: Pick; @@ -34,26 +36,21 @@ function memberKey(member: Pick): string { return normalizeMemberName(member.name); } -function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] { - const byName = new Map(); - for (const member of configMembers) { - const key = memberKey(member); - if (key) { - byName.set(key, member); - } +function providerIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined { + const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : ''; + if (normalized === 'codex-native') { + return 'codex'; } - for (const member of metaMembers) { - const key = memberKey(member); - if (key) { - byName.set(key, { ...byName.get(key), ...member }); - } + if (normalized === 'opencode-cli') { + return 'opencode'; } - return [...byName.values()]; + return undefined; } function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike { const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? + providerIdFromBackend(member.providerBackendId) ?? inferTeamProviderIdFromModel(member.model); return { name: member.name, @@ -74,7 +71,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { } const metaMembers = await this.deps.membersMetaStore.getMembers(teamName); - return mergeMembers(config.members ?? [], metaMembers) + return mergeTeamMembers(config.members ?? [], metaMembers) .filter((member) => !member.removedAt) .map((member) => normalizeMemberName(member.name)) .filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName)) @@ -107,7 +104,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { this.deps.kanbanManager.getState(input.teamName), this.deps.membersMetaStore.getMembers(input.teamName), ]); - const members = mergeMembers(config.members ?? [], metaMembers); + const members = mergeTeamMembers(config.members ?? [], metaMembers); const activeMemberNames = members .filter((member) => !member.removedAt) .map((member) => normalizeMemberName(member.name)) @@ -116,6 +113,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName); const providerId = normalizeOptionalTeamProviderId(member?.providerId) ?? + providerIdFromBackend(member?.providerBackendId) ?? inferTeamProviderIdFromModel(member?.model); const agenda = buildActionableWorkAgenda({ diff --git a/src/features/member-work-sync/main/adapters/output/mergeTeamMembers.ts b/src/features/member-work-sync/main/adapters/output/mergeTeamMembers.ts new file mode 100644 index 00000000..2ae3dbc1 --- /dev/null +++ b/src/features/member-work-sync/main/adapters/output/mergeTeamMembers.ts @@ -0,0 +1,110 @@ +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; + +import { normalizeMemberName } from '../../../core/domain'; + +import type { TeamMember, TeamProviderId } from '@shared/types'; + +function memberKey(member: Pick): string { + return normalizeMemberName(member.name); +} + +const PROVIDER_SCOPED_MEMBER_FIELDS = new Set([ + 'providerId', + 'providerBackendId', + 'model', + 'effort', + 'fastMode', +]); + +const PROVIDER_SETTING_MEMBER_FIELDS = new Set(['effort', 'fastMode']); + +function hasProviderIdentityFields(member: TeamMember | undefined): boolean { + return providerIdForMember(member) !== undefined; +} + +function inferProviderIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined { + const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : ''; + if (normalized === 'codex-native') { + return 'codex'; + } + if (normalized === 'opencode-cli') { + return 'opencode'; + } + return undefined; +} + +function providerIdForMember(member: TeamMember | undefined): TeamProviderId | undefined { + return ( + normalizeOptionalTeamProviderId(member?.providerId) ?? + inferProviderIdFromBackend(member?.providerBackendId) ?? + inferTeamProviderIdFromModel(member?.model) + ); +} + +function shouldPreserveBaseProviderScopedField( + base: TeamMember | undefined, + key: keyof TeamMember +): boolean { + if (!base || !PROVIDER_SCOPED_MEMBER_FIELDS.has(key)) { + return false; + } + if (hasProviderIdentityFields(base)) { + return true; + } + return PROVIDER_SETTING_MEMBER_FIELDS.has(key) && base[key] !== undefined; +} + +function mergeDefinedMemberFields(base: TeamMember | undefined, overlay: TeamMember): TeamMember { + const merged: TeamMember = { ...(base ?? { name: overlay.name }) }; + const overlayProviderId = normalizeOptionalTeamProviderId(overlay.providerId); + const overlayHasProviderId = overlayProviderId !== undefined; + const baseProviderId = providerIdForMember(base); + const providerChanged = + overlayHasProviderId && baseProviderId !== undefined && overlayProviderId !== baseProviderId; + if (providerChanged) { + for (const key of PROVIDER_SCOPED_MEMBER_FIELDS) { + delete merged[key]; + } + } + for (const [key, value] of Object.entries(overlay) as [ + keyof TeamMember, + TeamMember[keyof TeamMember], + ][]) { + if (value !== undefined) { + if (!overlayHasProviderId && shouldPreserveBaseProviderScopedField(base, key)) { + continue; + } + merged[key] = value as never; + } + } + if ( + Object.prototype.hasOwnProperty.call(overlay, 'removedAt') && + overlay.removedAt === undefined + ) { + delete merged.removedAt; + } + return merged; +} + +export function mergeTeamMembers( + configMembers: TeamMember[], + metaMembers: TeamMember[] +): TeamMember[] { + const byName = new Map(); + for (const member of configMembers) { + const key = memberKey(member); + if (key) { + byName.set(key, member); + } + } + for (const member of metaMembers) { + const key = memberKey(member); + if (key) { + byName.set(key, mergeDefinedMemberFields(byName.get(key), member)); + } + } + return [...byName.values()]; +} diff --git a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts index 4aedea90..6bce6519 100644 --- a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts +++ b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts @@ -1,6 +1,6 @@ +import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver'; import { describe, expect, it, vi } from 'vitest'; -import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver'; import type { TeamConfig } from '@shared/types'; describe('TeamRuntimeTurnSettledTargetResolver', () => { @@ -134,6 +134,48 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => { ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); }); + it('preserves config provider metadata when member meta lacks provider fields', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => []), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [ + { + name: 'Jack', + providerBackendId: 'codex-native', + model: 'opencode/openai/gpt-oss', + }, + ], + }) satisfies TeamConfig), + }, + membersMetaStore: { + getMembers: vi.fn(async () => [ + { + name: 'Jack', + role: 'developer', + agentType: 'general-purpose', + color: 'blue', + }, + ]), + } as never, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'opencode', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); + }); + it('resolves OpenCode turn-settled payloads from durable team/member identity', async () => { const resolver = new TeamRuntimeTurnSettledTargetResolver({ teamSource: { diff --git a/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts b/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts index 6ce94a7d..22ac54b7 100644 --- a/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts +++ b/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts @@ -1,6 +1,7 @@ +import { TeamTaskAgendaSource } from '@features/member-work-sync/main/adapters/output/TeamTaskAgendaSource'; import { describe, expect, it, vi } from 'vitest'; -import { TeamTaskAgendaSource } from '@features/member-work-sync/main/adapters/output/TeamTaskAgendaSource'; +import type { TeamConfig } from '@shared/types'; describe('TeamTaskAgendaSource', () => { it('applies kanban approved overlay before building member work agenda', async () => { @@ -52,4 +53,64 @@ describe('TeamTaskAgendaSource', () => { expect(result.agenda.items).toEqual([]); }); + + it('preserves config provider metadata when member meta only has runtime fields', async () => { + const source = new TeamTaskAgendaSource({ + configReader: { + getConfig: vi.fn(async () => ({ + name: 'forge-labs', + members: [ + { + name: 'Jack', + providerBackendId: 'codex-native', + model: 'opencode/openai/gpt-oss', + }, + ], + }) satisfies TeamConfig), + }, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-stale', + displayId: '#task-stale', + subject: 'Continue stale task', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + }, + ]), + }, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName: 'forge-labs', + reviewers: [], + tasks: {}, + })), + }, + membersMetaStore: { + getMembers: vi.fn(async () => [ + { + name: 'Jack', + role: 'developer', + agentType: 'general-purpose', + color: 'blue', + }, + ]), + }, + hash: { + sha256Hex: vi.fn((value: string) => `h${value.length}`), + }, + clock: { + now: () => new Date('2026-05-06T19:06:07.257Z'), + }, + } as never); + + const result = await source.loadAgenda({ + teamName: 'forge-labs', + memberName: 'jack', + }); + + expect(result.providerId).toBe('codex'); + expect(result.agenda.items).toHaveLength(1); + }); }); diff --git a/test/features/member-work-sync/main/adapters/output/mergeTeamMembers.test.ts b/test/features/member-work-sync/main/adapters/output/mergeTeamMembers.test.ts new file mode 100644 index 00000000..be1cd61a --- /dev/null +++ b/test/features/member-work-sync/main/adapters/output/mergeTeamMembers.test.ts @@ -0,0 +1,245 @@ +import { mergeTeamMembers } from '@features/member-work-sync/main/adapters/output/mergeTeamMembers'; +import { describe, expect, it } from 'vitest'; + +describe('mergeTeamMembers', () => { + it('preserves config provider fields when member meta only carries runtime fields', () => { + expect( + mergeTeamMembers( + [ + { + name: 'NickName', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + effort: 'medium', + }, + ], + [ + { + name: 'NickName', + role: 'developer', + agentType: 'general-purpose', + color: 'blue', + }, + ] + ) + ).toEqual([ + { + name: 'NickName', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + effort: 'medium', + role: 'developer', + agentType: 'general-purpose', + color: 'blue', + }, + ]); + }); + + it('allows explicit member meta values to override config values', () => { + expect( + mergeTeamMembers( + [{ name: 'Alice', providerId: 'codex', model: 'gpt-5.5', removedAt: undefined }], + [ + { + name: 'Alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + removedAt: 1780567089118, + }, + ] + ) + ).toEqual([ + { + name: 'Alice', + providerId: 'opencode', + model: 'minimax-m2.5-free', + removedAt: 1780567089118, + }, + ]); + }); + + it('clears stale config provider fields when explicit member meta changes provider', () => { + const [member] = mergeTeamMembers( + [ + { + name: 'Alice', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + effort: 'medium', + fastMode: 'off', + }, + ], + [{ name: 'Alice', providerId: 'opencode', role: 'developer' }] + ); + + expect(member).toEqual({ + name: 'Alice', + providerId: 'opencode', + role: 'developer', + }); + }); + + it('clears stale inferred config provider fields when explicit member meta changes provider', () => { + const [member] = mergeTeamMembers( + [ + { + name: 'Alice', + model: 'gpt-5.5', + effort: 'medium', + fastMode: 'off', + }, + ], + [{ name: 'Alice', providerId: 'opencode', role: 'developer' }] + ); + + expect(member).toEqual({ + name: 'Alice', + providerId: 'opencode', + role: 'developer', + }); + }); + + it('clears stale backend-inferred config provider fields when explicit member meta changes provider', () => { + const [member] = mergeTeamMembers( + [ + { + name: 'Alice', + providerBackendId: 'codex-native', + fastMode: 'off', + }, + ], + [{ name: 'Alice', providerId: 'opencode', role: 'developer' }] + ); + + expect(member).toEqual({ + name: 'Alice', + providerId: 'opencode', + role: 'developer', + }); + }); + + it('does not let providerless runtime meta model override config provider metadata', () => { + expect( + mergeTeamMembers( + [{ name: 'Alice', providerId: 'codex', model: 'gpt-5.5', effort: 'medium' }], + [ + { + name: 'Alice', + role: 'developer', + model: 'opencode/openai/gpt-oss', + effort: 'high', + }, + ] + ) + ).toEqual([ + { + name: 'Alice', + providerId: 'codex', + model: 'gpt-5.5', + effort: 'medium', + role: 'developer', + }, + ]); + }); + + it('preserves config fastMode off without dropping runtime provider identity fields', () => { + expect( + mergeTeamMembers( + [{ name: 'Alice', fastMode: 'off' }], + [{ name: 'Alice', model: 'opencode/openai/gpt-oss', fastMode: 'on' }] + ) + ).toEqual([{ name: 'Alice', fastMode: 'off', model: 'opencode/openai/gpt-oss' }]); + }); + + it('preserves backend-only config provider metadata over providerless runtime meta model', () => { + expect( + mergeTeamMembers( + [{ name: 'Alice', providerBackendId: 'codex-native' }], + [{ name: 'Alice', model: 'opencode/openai/gpt-oss', role: 'developer' }] + ) + ).toEqual([ + { + name: 'Alice', + providerBackendId: 'codex-native', + role: 'developer', + }, + ]); + }); + + it('treats provider backend as stronger provider identity than a stale model', () => { + const [member] = mergeTeamMembers( + [ + { + name: 'Alice', + providerBackendId: 'opencode-cli', + model: 'gpt-5.5', + fastMode: 'off', + }, + ], + [{ name: 'Alice', providerId: 'codex', role: 'developer' }] + ); + + expect(member).toEqual({ + name: 'Alice', + providerId: 'codex', + role: 'developer', + }); + }); + + it('does not treat empty or null config provider metadata as authoritative', () => { + const [member] = mergeTeamMembers( + [{ name: 'Alice', model: '', providerBackendId: null as never }], + [{ name: 'Alice', model: 'gpt-5.5', role: 'developer' }] + ); + + expect(member).toMatchObject({ + name: 'Alice', + model: 'gpt-5.5', + role: 'developer', + }); + }); + + it('does not treat an uninferable config model as authoritative provider identity', () => { + const [member] = mergeTeamMembers( + [{ name: 'Alice', model: 'custom-local-model', fastMode: 'off' }], + [{ name: 'Alice', model: 'gpt-5.5', role: 'developer' }] + ); + + expect(member).toEqual({ + name: 'Alice', + model: 'gpt-5.5', + fastMode: 'off', + role: 'developer', + }); + }); + + it('allows runtime member meta to clear stale config removal state without clearing provider fields', () => { + const [member] = mergeTeamMembers( + [{ name: 'Alice', providerId: 'codex', model: 'gpt-5.5', removedAt: 1780567089118 }], + [ + { + name: 'Alice', + role: 'developer', + removedAt: undefined, + }, + ] + ); + + expect(member).toMatchObject({ + name: 'Alice', + providerId: 'codex', + model: 'gpt-5.5', + role: 'developer', + }); + expect(member).not.toHaveProperty('removedAt'); + }); + + it('keeps meta-only members', () => { + expect( + mergeTeamMembers([], [{ name: 'Bob', role: 'reviewer', color: 'green' }]) + ).toEqual([{ name: 'Bob', role: 'reviewer', color: 'green' }]); + }); +});