fix(member-work-sync): preserve provider metadata when merging members

This commit is contained in:
777genius 2026-06-06 21:19:18 +03:00
parent 336baf6a57
commit c322031542
6 changed files with 487 additions and 34 deletions

View file

@ -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<TeamSummary[]>;
@ -38,26 +40,21 @@ function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
const byName = new Map<string, TeamMember>();
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
);

View file

@ -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<TeamConfigReader, 'getConfig'>;
@ -34,26 +36,21 @@ function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
const byName = new Map<string, TeamMember>();
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({

View file

@ -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<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
const PROVIDER_SCOPED_MEMBER_FIELDS = new Set<keyof TeamMember>([
'providerId',
'providerBackendId',
'model',
'effort',
'fastMode',
]);
const PROVIDER_SETTING_MEMBER_FIELDS = new Set<keyof TeamMember>(['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<string, TeamMember>();
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()];
}

View file

@ -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: {

View file

@ -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);
});
});

View file

@ -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' }]);
});
});