fix(member-work-sync): preserve provider metadata when merging members
This commit is contained in:
parent
336baf6a57
commit
c322031542
6 changed files with 487 additions and 34 deletions
|
|
@ -7,6 +7,8 @@ import path from 'path';
|
||||||
|
|
||||||
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
|
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
|
||||||
|
|
||||||
|
import { mergeTeamMembers } from './mergeTeamMembers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
RuntimeTurnSettledTargetResolution,
|
RuntimeTurnSettledTargetResolution,
|
||||||
RuntimeTurnSettledTargetResolverPort,
|
RuntimeTurnSettledTargetResolverPort,
|
||||||
|
|
@ -14,7 +16,7 @@ import type {
|
||||||
import type { RuntimeTurnSettledEvent } from '../../../core/domain';
|
import type { RuntimeTurnSettledEvent } from '../../../core/domain';
|
||||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
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 {
|
export interface RuntimeTurnSettledTeamSource {
|
||||||
listTeams(): Promise<TeamSummary[]>;
|
listTeams(): Promise<TeamSummary[]>;
|
||||||
|
|
@ -38,26 +40,21 @@ function memberKey(member: Pick<TeamMember, 'name'>): string {
|
||||||
return normalizeMemberName(member.name);
|
return normalizeMemberName(member.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
|
function providerIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
|
||||||
const byName = new Map<string, TeamMember>();
|
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
|
||||||
for (const member of configMembers) {
|
if (normalized === 'codex-native') {
|
||||||
const key = memberKey(member);
|
return 'codex';
|
||||||
if (key) {
|
|
||||||
byName.set(key, member);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const member of metaMembers) {
|
if (normalized === 'opencode-cli') {
|
||||||
const key = memberKey(member);
|
return 'opencode';
|
||||||
if (key) {
|
|
||||||
byName.set(key, { ...byName.get(key), ...member });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [...byName.values()];
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerForMember(member: TeamMember | undefined): string | undefined {
|
function providerForMember(member: TeamMember | undefined): TeamProviderId | undefined {
|
||||||
return (
|
return (
|
||||||
normalizeOptionalTeamProviderId(member?.providerId) ??
|
normalizeOptionalTeamProviderId(member?.providerId) ??
|
||||||
|
providerIdFromBackend(member?.providerBackendId) ??
|
||||||
inferTeamProviderIdFromModel(member?.model)
|
inferTeamProviderIdFromModel(member?.model)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +199,7 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
|
||||||
|
|
||||||
const normalizedTarget = normalizeMemberName(memberName);
|
const normalizedTarget = normalizeMemberName(memberName);
|
||||||
return (
|
return (
|
||||||
mergeMembers(config.members ?? [], metaMembers).find(
|
mergeTeamMembers(config.members ?? [], metaMembers).find(
|
||||||
(member) => !member.removedAt && memberKey(member) === normalizedTarget
|
(member) => !member.removedAt && memberKey(member) === normalizedTarget
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
normalizeMemberName,
|
normalizeMemberName,
|
||||||
} from '../../../core/domain';
|
} from '../../../core/domain';
|
||||||
|
|
||||||
|
import { mergeTeamMembers } from './mergeTeamMembers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncAgendaSourcePort,
|
MemberWorkSyncAgendaSourcePort,
|
||||||
MemberWorkSyncAgendaSourceResult,
|
MemberWorkSyncAgendaSourceResult,
|
||||||
|
|
@ -19,7 +21,7 @@ import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||||
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||||
import type { TeamMember } from '@shared/types';
|
import type { TeamMember, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
export interface TeamTaskAgendaSourceDeps {
|
export interface TeamTaskAgendaSourceDeps {
|
||||||
configReader: Pick<TeamConfigReader, 'getConfig'>;
|
configReader: Pick<TeamConfigReader, 'getConfig'>;
|
||||||
|
|
@ -34,26 +36,21 @@ function memberKey(member: Pick<TeamMember, 'name'>): string {
|
||||||
return normalizeMemberName(member.name);
|
return normalizeMemberName(member.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
|
function providerIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
|
||||||
const byName = new Map<string, TeamMember>();
|
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
|
||||||
for (const member of configMembers) {
|
if (normalized === 'codex-native') {
|
||||||
const key = memberKey(member);
|
return 'codex';
|
||||||
if (key) {
|
|
||||||
byName.set(key, member);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const member of metaMembers) {
|
if (normalized === 'opencode-cli') {
|
||||||
const key = memberKey(member);
|
return 'opencode';
|
||||||
if (key) {
|
|
||||||
byName.set(key, { ...byName.get(key), ...member });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [...byName.values()];
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike {
|
function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike {
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeOptionalTeamProviderId(member.providerId) ??
|
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||||
|
providerIdFromBackend(member.providerBackendId) ??
|
||||||
inferTeamProviderIdFromModel(member.model);
|
inferTeamProviderIdFromModel(member.model);
|
||||||
return {
|
return {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
|
@ -74,7 +71,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaMembers = await this.deps.membersMetaStore.getMembers(teamName);
|
const metaMembers = await this.deps.membersMetaStore.getMembers(teamName);
|
||||||
return mergeMembers(config.members ?? [], metaMembers)
|
return mergeTeamMembers(config.members ?? [], metaMembers)
|
||||||
.filter((member) => !member.removedAt)
|
.filter((member) => !member.removedAt)
|
||||||
.map((member) => normalizeMemberName(member.name))
|
.map((member) => normalizeMemberName(member.name))
|
||||||
.filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName))
|
.filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName))
|
||||||
|
|
@ -107,7 +104,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
||||||
this.deps.kanbanManager.getState(input.teamName),
|
this.deps.kanbanManager.getState(input.teamName),
|
||||||
this.deps.membersMetaStore.getMembers(input.teamName),
|
this.deps.membersMetaStore.getMembers(input.teamName),
|
||||||
]);
|
]);
|
||||||
const members = mergeMembers(config.members ?? [], metaMembers);
|
const members = mergeTeamMembers(config.members ?? [], metaMembers);
|
||||||
const activeMemberNames = members
|
const activeMemberNames = members
|
||||||
.filter((member) => !member.removedAt)
|
.filter((member) => !member.removedAt)
|
||||||
.map((member) => normalizeMemberName(member.name))
|
.map((member) => normalizeMemberName(member.name))
|
||||||
|
|
@ -116,6 +113,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
||||||
const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName);
|
const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName);
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeOptionalTeamProviderId(member?.providerId) ??
|
normalizeOptionalTeamProviderId(member?.providerId) ??
|
||||||
|
providerIdFromBackend(member?.providerBackendId) ??
|
||||||
inferTeamProviderIdFromModel(member?.model);
|
inferTeamProviderIdFromModel(member?.model);
|
||||||
|
|
||||||
const agenda = buildActionableWorkAgenda({
|
const agenda = buildActionableWorkAgenda({
|
||||||
|
|
|
||||||
|
|
@ -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()];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
|
||||||
import type { TeamConfig } from '@shared/types';
|
import type { TeamConfig } from '@shared/types';
|
||||||
|
|
||||||
describe('TeamRuntimeTurnSettledTargetResolver', () => {
|
describe('TeamRuntimeTurnSettledTargetResolver', () => {
|
||||||
|
|
@ -134,6 +134,48 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => {
|
||||||
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
|
).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 () => {
|
it('resolves OpenCode turn-settled payloads from durable team/member identity', async () => {
|
||||||
const resolver = new TeamRuntimeTurnSettledTargetResolver({
|
const resolver = new TeamRuntimeTurnSettledTargetResolver({
|
||||||
teamSource: {
|
teamSource: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import { TeamTaskAgendaSource } from '@features/member-work-sync/main/adapters/output/TeamTaskAgendaSource';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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', () => {
|
describe('TeamTaskAgendaSource', () => {
|
||||||
it('applies kanban approved overlay before building member work agenda', async () => {
|
it('applies kanban approved overlay before building member work agenda', async () => {
|
||||||
|
|
@ -52,4 +53,64 @@ describe('TeamTaskAgendaSource', () => {
|
||||||
|
|
||||||
expect(result.agenda.items).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue