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